182

I'm trying to create a button which has some text beneath the icon (sorta like the app buttons) however it seems to be quite difficult to achieve. Any ideas how can I go about get the text to display below the image with a UIButton?

Paulo Mattos
  • 18,845
  • 10
  • 77
  • 85
NRaf
  • 7,407
  • 13
  • 52
  • 91
  • It is fairly easy and doable to make a custom subclass of UIbutton containing a UIImage and UILabel, positioned like you would need... – NP Compete Nov 17 '10 at 06:43
  • 7
    Or just use a UIButton and UILabel. – raidfive Nov 17 '10 at 06:49
  • To precisely control with the size and auto layout, you can try this: `https://github.com/albert-zhang/AZCenterLabelButton` ([Link](https://github.com/albert-zhang/AZCenterLabelButton)) – Albert Zhang Dec 18 '14 at 02:55
  • works fine with this solution https://stackoverflow.com/a/59666154/1576134 – Shreyank Jan 09 '20 at 14:32
  • From **Xcode 13** there is option in storyboard just change the button **Placement** option to **top** that work fine for me – Reema Jan 22 '22 at 10:55

35 Answers35

133

Or you can just use this category:

ObjC

@interface UIButton (VerticalLayout)

- (void)centerVerticallyWithPadding:(float)padding;
- (void)centerVertically;

@end

@implementation UIButton (VerticalLayout)

- (void)centerVerticallyWithPadding:(float)padding {
    CGSize imageSize = self.imageView.frame.size;
    CGSize titleSize = self.titleLabel.frame.size;
    CGFloat totalHeight = (imageSize.height + titleSize.height + padding);
    
    self.imageEdgeInsets = UIEdgeInsetsMake(- (totalHeight - imageSize.height),
                                            0.0f,
                                            0.0f,
                                            - titleSize.width);
    
    self.titleEdgeInsets = UIEdgeInsetsMake(0.0f,
                                            - imageSize.width,
                                            - (totalHeight - titleSize.height),
                                            0.0f);
    
    self.contentEdgeInsets = UIEdgeInsetsMake(0.0f,
                                              0.0f,
                                              titleSize.height,
                                              0.0f);
}

- (void)centerVertically {
    const CGFloat kDefaultPadding = 6.0f;
    [self centerVerticallyWithPadding:kDefaultPadding];
}

@end

Swift extension

extension UIButton {
    
    func centerVertically(padding: CGFloat = 6.0) {
        guard
            let imageViewSize = self.imageView?.frame.size,
            let titleLabelSize = self.titleLabel?.frame.size else {
            return
        }
        
        let totalHeight = imageViewSize.height + titleLabelSize.height + padding
        
        self.imageEdgeInsets = UIEdgeInsets(
            top: -(totalHeight - imageViewSize.height),
            left: 0.0,
            bottom: 0.0,
            right: -titleLabelSize.width
        )
        
        self.titleEdgeInsets = UIEdgeInsets(
            top: 0.0,
            left: -imageViewSize.width,
            bottom: -(totalHeight - titleLabelSize.height),
            right: 0.0
        )
        
        self.contentEdgeInsets = UIEdgeInsets(
            top: 0.0,
            left: 0.0,
            bottom: titleLabelSize.height,
            right: 0.0
        )
    }
    
}

Suggestion: If button height is less than totalHeight, then image will draw outside borders.

imageEdgeInset.top should be:

max(0, -(totalHeight - imageViewSize.height))
Community
  • 1
  • 1
Rafał Sroka
  • 39,540
  • 23
  • 113
  • 143
  • 6
    I think this is the best answer since it uses edgeInsets instead of manually adjusting the frame. It works great with auto layout too when called from layoutSubviews in the button's superview. Only suggestion is to use `CGRectGetHeight()` and `CGRectGetWidth()` when getting the imageView and titleLabel height and width. – Jesse Oct 24 '14 at 18:35
  • 2
    When I use this the image pops above the button view, to center it should I `CGFloat inset = (self.frame.size.height - totalHeight)/2; self.contentEdgeInsets = UIEdgeInsetsMake(inset, 0.0f, inset, 0.0f);` – Alex Hedley Aug 04 '17 at 10:03
  • 15
    The Swift extension did not layout it out correctly for me. – Patrick Aug 09 '18 at 11:34
  • It work if Image was set as setImage, not as setBackgroundImage. – Argus Oct 31 '18 at 16:05
  • The solution is not working for me. The following values are set into imageViewSize and TitleLabelSize: mageViewSize = (CGSize) (width = 0, height = 0), titleLabelSize (CGSize) (width = 0, height = 18) – Spasitel Oct 13 '19 at 15:09
  • Do you mean center "horizontally"? OP described they want the title label below the image.. which would mean they are centered horizontally, and not vertically. – vikzilla Nov 07 '19 at 00:07
  • 1
    works fine with this solution https://stackoverflow.com/a/59666154/1576134 – Shreyank Jan 09 '20 at 14:31
  • I was able to force this to do what I needed, by commenting out the part that modified contentEdgeInsets. You do need to call this method in an override of didLayoutSubviews, or the position will be wonky (off to the right and down a bit in my case). – Oscar Mar 03 '20 at 20:17
94

In Xcode, you can simply set the Edge Title Left Inset to negative the width of the image. This will display the label in the center of the image.

To get the label to display below the image (sorta like the app buttons), you may need to set the Edge Title Top Inset to some positive number.

Edit: Here is some code to achieve that without using Interface Builder:

/// This will move the TitleLabel text of a UIButton to below it's Image and Centered.
/// Note: No point in calling this function before autolayout lays things out.
/// - Parameter padding: Some extra padding to be applied
func centerVertically(padding: CGFloat = 18.0) {
    // No point in doing anything if we don't have an imageView size
    guard let imageFrame = imageView?.frame else { return }
    titleLabel?.numberOfLines = 0
    titleEdgeInsets.left = -(imageFrame.width + padding)
    titleEdgeInsets.top = (imageFrame.height + padding)
}

Please note this won't work if you're using autolayout and the button didn't get layed out in the screen yet via constraints.

C0D3
  • 6,440
  • 8
  • 43
  • 67
Chris
  • 3,577
  • 1
  • 21
  • 15
  • 1
    This is the way to do it... unless you're doing this repeatedly with a number of buttons (of various sizes)... in which case I had good results with a tweaked version of Erik W's solution – Kenny Winker Jul 11 '13 at 22:28
  • 5
    Just to make sure people realize this. The value should be the negative width of the image, even if the button is wider than the width of the image. – Liron Sep 09 '13 at 12:49
  • 1
    This did not work for me. My text still appears to the right of the image, i.e. does not wrap below it. – Erika Electra Oct 30 '13 at 12:37
  • 1
    @Cindeselia Thats surprising. How big of a value did you use for the Top Inset? Maybe try increasing it to an even larger value? – Chris Nov 01 '13 at 01:54
  • unfortunately, there are alignment differences between ios versions with this method – Radu Simionescu Apr 04 '14 at 21:45
  • 3
    In iOS7, it seems not work. Label only moves to bottom of image and hidden, not show anymore. – Brave Jun 06 '14 at 20:03
  • I had to use negative the button's frame width, rather than negative the button's image frame width. – Chucky Jul 24 '17 at 12:29
  • this doesn't work. with the insets the placement is totally different on the device/simulator and don't match up to that in IB – Hogdotmac Sep 12 '17 at 09:29
  • The problem I have is, the image ends up left-aligned. This is bad when the button ends up being wider than the image... – Nicolas Miari Apr 03 '18 at 01:14
  • I used the edge insets method proposed by Chris, but since the button width was smaller than the total width of both the image and the title of the button, the title showed as 3 dots (...). I couldn't increase the width of the button due to layout reasons. I solved this by changing the Line Break attribute to: "Clip". That makes the title go down beneath the image. Then I corrected its location using the insets. This works only when the above problem exists. – Shaked Sayag May 27 '15 at 15:59
  • why `negative the width of the image` will make the text to the middle ? – zionpi Sep 13 '20 at 03:23
  • @zionpi pulling the label on the x-axis in negative direction will bring the label towards left. – C0D3 Sep 28 '20 at 15:25
  • I think this should be the selected answer as other answers with a UIButton extension centerVertically() function didn't work for me. I briefly tried subclassing but that seems like a lot of work for something simple I wanted to achieve. Changing the titleEdgeInsets.left and top seems to work! – C0D3 Sep 28 '20 at 15:27
57

This is a simple centered title button implemented in Swift by overriding titleRect(forContentRect:) and imageRect(forContentRect:). It also implements intrinsicContentSize for use with AutoLayout.

import UIKit

class CenteredButton: UIButton
{
    override func titleRect(forContentRect contentRect: CGRect) -> CGRect {
        let rect = super.titleRect(forContentRect: contentRect)

        return CGRect(x: 0, y: contentRect.height - rect.height + 5,
            width: contentRect.width, height: rect.height)
    }

    override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
        let rect = super.imageRect(forContentRect: contentRect)
        let titleRect = self.titleRect(forContentRect: contentRect)

        return CGRect(x: contentRect.width/2.0 - rect.width/2.0,
            y: (contentRect.height - titleRect.height)/2.0 - rect.height/2.0,
            width: rect.width, height: rect.height)
    }

    override var intrinsicContentSize: CGSize {
        let size = super.intrinsicContentSize

        if let image = imageView?.image {
            var labelHeight: CGFloat = 0.0

            if let size = titleLabel?.sizeThatFits(CGSize(width: self.contentRect(forBounds: self.bounds).width, height: CGFloat.greatestFiniteMagnitude)) {
                labelHeight = size.height
            }

            return CGSize(width: size.width, height: image.size.height + labelHeight + 5)
        }

        return size
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        centerTitleLabel()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        centerTitleLabel()
    }

    private func centerTitleLabel() {
        self.titleLabel?.textAlignment = .center
    }
}
simeon
  • 4,466
  • 2
  • 38
  • 42
  • 8
    That is the most correct solution. But some modification needed for intrinsic content size. It should return MAX width between image and label: return CGSizeMake(MAX(labelSize.width, self.imageView.image.size.width), self.imageView.image.size.height + labelHeight) – kirander Nov 26 '15 at 09:59
  • It's not working for me when title has two lines. – Radek Wilczak Jan 16 '23 at 10:32
46

Look at this great answer in Swift.

extension UIButton {

    func alignImageAndTitleVertically(padding: CGFloat = 6.0) {
        let imageSize = self.imageView!.frame.size
        let titleSize = self.titleLabel!.frame.size
        let totalHeight = imageSize.height + titleSize.height + padding

        self.imageEdgeInsets = UIEdgeInsets(
            top: -(totalHeight - imageSize.height),
            left: 0,
            bottom: 0,
            right: -titleSize.width
        )

        self.titleEdgeInsets = UIEdgeInsets(
            top: 0,
            left: -imageSize.width,
            bottom: -(totalHeight - titleSize.height),
            right: 0
        )
    }

}
Jonathan Hall
  • 75,165
  • 16
  • 143
  • 189
Tiago
  • 3,113
  • 2
  • 31
  • 45
  • 4
    If you also want the image centered vertically, replace `left` in `imageEdgeInsets` with `(self.frame.size.width - imageSize.width) / 2` – elsurudo Sep 20 '16 at 08:21
  • If you are using autolayout call this method in `layoutSubviews()` of your superview. – AlexVogel Aug 16 '19 at 07:31
  • Who is setting the `imageView`'s frame? Wouldn't it be better if you used `imageView?.image.size`? – Zorayr Aug 17 '20 at 19:56
33

Subclass UIButton. Override -layoutSubviews to move the built-in subviews into new positions:

- (void)layoutSubviews
{
    [super layoutSubviews];

    CGRect frame = self.imageView.frame;
    frame = CGRectMake(truncf((self.bounds.size.width - frame.size.width) / 2), 0.0f, frame.size.width, frame.size.height);
    self.imageView.frame = frame;

    frame = self.titleLabel.frame;
    frame = CGRectMake(truncf((self.bounds.size.width - frame.size.width) / 2), self.bounds.size.height - frame.size.height, frame.size.width, frame.size.height);
    self.titleLabel.frame = frame;
}
Rishil Patel
  • 1,977
  • 3
  • 14
  • 30
Dave Batton
  • 8,795
  • 1
  • 46
  • 50
  • I personally had to set the titleLabel y value to 0 and the height to the frame height for it to display the text with the image. It doesn't make sense to me but it works... though I'm still learning the 'Apple' way of setting up controls. – Russ Mar 07 '12 at 18:31
  • 7
    Actually, the better way is to override `titleRectForContentRect` and `imageRectForContentRect` – Mazyod Aug 04 '15 at 21:11
32

Refactored icecrystal23`s answer.

Swift 3, works with autolayouts, xib, storyboards, can be animated.

Button in orginal icecrystal23`s answer had a badly calculated frame. I think I fixed that.

Edit: Updated to Swift 5 and made work inside Interface Builder / Storyboards

Edit: Updated to support localization (ltr/rtl) per Alexsander Akers' comment on the answer below

import UIKit

@IBDesignable
class VerticalButton: UIButton {

    @IBInspectable public var padding: CGFloat = 20.0 {
        didSet {
            setNeedsLayout()
        }
    }
    
    override var intrinsicContentSize: CGSize {
        let maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        
        if let titleSize = titleLabel?.sizeThatFits(maxSize), let imageSize = imageView?.sizeThatFits(maxSize) {
            let width = ceil(max(imageSize.width, titleSize.width))
            let height = ceil(imageSize.height + titleSize.height + padding)
            
            return CGSize(width: width, height: height)
        }
        
        return super.intrinsicContentSize
    }
    
    override func layoutSubviews() {
        if let image = imageView?.image, let title = titleLabel?.attributedText {
            let imageSize = image.size
            let titleSize = title.size()
            
            if effectiveUserInterfaceLayoutDirection == .leftToRight {
                titleEdgeInsets = UIEdgeInsets(top: 0.0, left: -imageSize.width, bottom: -(imageSize.height + padding), right: 0.0)
                imageEdgeInsets = UIEdgeInsets(top: -(titleSize.height + padding), left: 0.0, bottom: 0.0, right: -titleSize.width)
            }
            else {
                titleEdgeInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: -(imageSize.height + padding), right: -imageSize.width)
                imageEdgeInsets = UIEdgeInsets(top: -(titleSize.height + padding), left: -titleSize.width, bottom: 0.0, right: 0.0)
            }
        }
        
        super.layoutSubviews()
    }

}
Andy Weinstein
  • 2,639
  • 3
  • 21
  • 32
Kamil Harasimowicz
  • 4,684
  • 5
  • 32
  • 58
  • 2
    There is an issue with this when the image is removed. I am using an image for selected state and no image for default state. When the state is changed from selected to default the label is messed up. So a few fixes are needed: Do not check image view but use 'image(for: state)'. Set zero edge insets when there is no image in else statement of layoutSubviews. – Matic Oblak Sep 18 '17 at 12:16
  • Only solution here that works. Other answers seem to work, but actually the button bounds don't resize according to label and image size. Set a background color to see this. – Manuel Nov 27 '18 at 19:47
  • This solution didn't work, I think it caused some sort of an infinite loop and eventually Xcode crash. I removed the intrinsicContentSize part and it worked fine (Xcode 11.5) – mojuba Jul 09 '20 at 13:31
  • And you can support your `contentEdgeInsets` by computing CGSize as follows: `CGSize(width: width + contentEdgeInsets.left + contentEdgeInsets.right, height: height + contentEdgeInsets.top + contentEdgeInsets.bottom)` – androidguy Oct 20 '20 at 01:13
24

corrected one of the answers here:

Swift 3:

class CenteredButton: UIButton
{
    override func titleRect(forContentRect contentRect: CGRect) -> CGRect {
        let rect = super.titleRect(forContentRect: contentRect)
        let imageRect = super.imageRect(forContentRect: contentRect)

        return CGRect(x: 0, y: imageRect.maxY + 10,
                      width: contentRect.width, height: rect.height)
    }

    override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
        let rect = super.imageRect(forContentRect: contentRect)
        let titleRect = self.titleRect(forContentRect: contentRect)

        return CGRect(x: contentRect.width/2.0 - rect.width/2.0,
                      y: (contentRect.height - titleRect.height)/2.0 - rect.height/2.0,
                      width: rect.width, height: rect.height)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        centerTitleLabel()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        centerTitleLabel()
    }

    private func centerTitleLabel() {
        self.titleLabel?.textAlignment = .center
    }
}
Alex Shubin
  • 3,549
  • 1
  • 27
  • 32
18

Dave's solution in Swift:

override func layoutSubviews() {
    super.layoutSubviews()
    if let imageView = self.imageView {
        imageView.frame.origin.x = (self.bounds.size.width - imageView.frame.size.width) / 2.0
        imageView.frame.origin.y = 0.0
    }
    if let titleLabel = self.titleLabel {
        titleLabel.frame.origin.x = (self.bounds.size.width - titleLabel.frame.size.width) / 2.0
        titleLabel.frame.origin.y = self.bounds.size.height - titleLabel.frame.size.height
    }
}
Bartosz Hernas
  • 1,130
  • 1
  • 9
  • 17
17

If you subclass UIButton and override layoutSubviews, you can use the below to center the image and place the title centered below it:

kTextTopPadding is a constant you'll have to introduce that determines the space between the image and the text below it.

-(void)layoutSubviews {
    [super layoutSubviews];

    // Move the image to the top and center it horizontally
    CGRect imageFrame = self.imageView.frame;
    imageFrame.origin.y = 0;
    imageFrame.origin.x = (self.frame.size.width / 2) - (imageFrame.size.width / 2);
    self.imageView.frame = imageFrame;

    // Adjust the label size to fit the text, and move it below the image
    CGRect titleLabelFrame = self.titleLabel.frame;
    CGSize labelSize = [self.titleLabel.text sizeWithFont:self.titleLabel.font
                                        constrainedToSize:CGSizeMake(self.frame.size.width, CGFLOAT_MAX)
                                        lineBreakMode:NSLineBreakByWordWrapping];
    titleLabelFrame.size.width = labelSize.width;
    titleLabelFrame.size.height = labelSize.height;
    titleLabelFrame.origin.x = (self.frame.size.width / 2) - (labelSize.width / 2);
    titleLabelFrame.origin.y = self.imageView.frame.origin.y + self.imageView.frame.size.height + kTextTopPadding;
    self.titleLabel.frame = titleLabelFrame;

}
Rishil Patel
  • 1,977
  • 3
  • 14
  • 30
Erik W
  • 807
  • 9
  • 25
16

This is a modified version of Erik W's excellent answer. But instead of placing the image centered at the TOP of the view, it places the image and the label centred in the view as a group.

The difference is:

+-----------+
|    ( )    |
|   Hello   |     // Erik W's code
|           |
|           |
+-----------+

vs

+-----------+
|           |
|    ( )    |     // My modified version
|   Hello   |
|           |
+-----------+

Source below:

-(void)layoutSubviews {
    [super layoutSubviews];

    CGRect titleLabelFrame = self.titleLabel.frame;
    CGSize labelSize = [self.titleLabel.text sizeWithFont:self.titleLabel.font constrainedToSize:CGSizeMake(self.frame.size.width, CGFLOAT_MAX) lineBreakMode:NSLineBreakByWordWrapping];

    CGRect imageFrame = self.imageView.frame;

    CGSize fitBoxSize = (CGSize){.height = labelSize.height + kTextTopPadding +  imageFrame.size.height, .width = MAX(imageFrame.size.width, labelSize.width)};

    CGRect fitBoxRect = CGRectInset(self.bounds, (self.bounds.size.width - fitBoxSize.width)/2, (self.bounds.size.height - fitBoxSize.height)/2);

    imageFrame.origin.y = fitBoxRect.origin.y;
    imageFrame.origin.x = CGRectGetMidX(fitBoxRect) - (imageFrame.size.width/2);
    self.imageView.frame = imageFrame;

    // Adjust the label size to fit the text, and move it below the image

    titleLabelFrame.size.width = labelSize.width;
    titleLabelFrame.size.height = labelSize.height;
    titleLabelFrame.origin.x = (self.frame.size.width / 2) - (labelSize.width / 2);
    titleLabelFrame.origin.y = fitBoxRect.origin.y + imageFrame.size.height + kTextTopPadding;
    self.titleLabel.frame = titleLabelFrame;
}

FYI: This can break when combined with UIView animations, as layoutSubviews is called during them.

Kenny Winker
  • 11,919
  • 7
  • 56
  • 78
13

On iOS 11/ Swift 4 none of the above answers really worked for me. I found some examples and put my spin on it:

extension UIButton {

    func centerImageAndButton(_ gap: CGFloat, imageOnTop: Bool) {

      guard let imageView = self.currentImage,
      let titleLabel = self.titleLabel?.text else { return }

      let sign: CGFloat = imageOnTop ? 1 : -1
      self.titleEdgeInsets = UIEdgeInsetsMake((imageView.size.height + gap) * sign, -imageView.size.width, 0, 0);

      let titleSize = titleLabel.size(withAttributes:[NSAttributedStringKey.font: self.titleLabel!.font!])
      self.imageEdgeInsets = UIEdgeInsetsMake(-(titleSize.height + gap) * sign, 0, 0, -titleSize.width)
    }
}

Hope this helps someone.

Roman B
  • 141
  • 1
  • 2
8

Updated Kenny Winker's answer since sizeWithFont was deprecated in iOS 7.

-(void)layoutSubviews {
[super layoutSubviews];

int kTextTopPadding = 3;

CGRect titleLabelFrame = self.titleLabel.frame;

CGRect labelSize = [self.titleLabel.text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGRectGetHeight(self.bounds)) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:self.titleLabel.font} context:nil];

CGRect imageFrame = self.imageView.frame;

CGSize fitBoxSize = (CGSize){.height = labelSize.size.height + kTextTopPadding +  imageFrame.size.height, .width = MAX(imageFrame.size.width, labelSize.size.width)};

CGRect fitBoxRect = CGRectInset(self.bounds, (self.bounds.size.width - fitBoxSize.width)/2, (self.bounds.size.height - fitBoxSize.height)/2);

imageFrame.origin.y = fitBoxRect.origin.y;
imageFrame.origin.x = CGRectGetMidX(fitBoxRect) - (imageFrame.size.width/2);
self.imageView.frame = imageFrame;

// Adjust the label size to fit the text, and move it below the image

titleLabelFrame.size.width = labelSize.size.width;
titleLabelFrame.size.height = labelSize.size.height;
titleLabelFrame.origin.x = (self.frame.size.width / 2) - (labelSize.size.width / 2);
titleLabelFrame.origin.y = fitBoxRect.origin.y + imageFrame.size.height + kTextTopPadding;
self.titleLabel.frame = titleLabelFrame;
}
Joped
  • 1,108
  • 1
  • 11
  • 12
8

Swift 5 - below method works for me

func centerVerticallyWithPadding(padding : CGFloat) {
        guard
            let imageViewSize = self.imageView?.frame.size,
            let titleLabelSize = self.titleLabel?.frame.size else {
            return
        }

        let totalHeight = imageViewSize.height + titleLabelSize.height + padding

        self.imageEdgeInsets = UIEdgeInsets(
            top: max(0, -(totalHeight - imageViewSize.height)),
            left: 0.0,
            bottom: 0.0,
            right: -titleLabelSize.width
        )

        self.titleEdgeInsets = UIEdgeInsets(
            top: (totalHeight - imageViewSize.height),
            left: -imageViewSize.width,
            bottom: -(totalHeight - titleLabelSize.height),
            right: 0.0
        )

        self.contentEdgeInsets = UIEdgeInsets(
            top: 0.0,
            left: 0.0,
            bottom: titleLabelSize.height,
            right: 0.0
        )
    }

Make sure your button title is not truncate in storyboard/xib else go for
Solution 2

class SVVerticalButton: UIButton {

    override func layoutSubviews() {
        super.layoutSubviews()
        let padding : CGFloat = 2.0
        if let imageView = self.imageView {
            imageView.frame.origin.x = (self.bounds.size.width - imageView.frame.size.width) / 2.0
            imageView.frame.origin.y = max(0,(self.bounds.size.height - (imageView.frame.size.height + (titleLabel?.frame.size.height ?? 0.0) + padding)) / 2.0)
        }
        if let titleLabel = self.titleLabel {
            titleLabel.frame.origin.x = 0
            titleLabel.frame.origin.y = self.bounds.size.height - titleLabel.frame.size.height
            titleLabel.frame.size.width = self.bounds.size.width
            titleLabel.textAlignment = .center
        }
    }

}
Shreyank
  • 1,549
  • 13
  • 24
8

Swift 5, AutoLayout

I implemented combination of some approaches and this works best for me. Trick is in overriding titleRect(forContentRect:), imageRect(forContentRect:) methods and intrinsicContentSize getter with calculated values of those views.

Result:

enter image description here

final class CustomButton: UIButton {

    override func titleRect(forContentRect contentRect: CGRect) -> CGRect {
        let superRect = super.titleRect(forContentRect: contentRect)
        return CGRect(
            x: 0,
            y: contentRect.height - superRect.height,
            width: contentRect.width,
            height: superRect.height
        )
    }

    override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
        let superRect = super.imageRect(forContentRect: contentRect)
        return CGRect(
            x: contentRect.width / 2 - superRect.width / 2,
            y: (contentRect.height - titleRect(forContentRect: contentRect).height) / 2 - superRect.height / 2,
            width: superRect.width,
            height: superRect.height
        )
    }

    override var intrinsicContentSize: CGSize {
        _ = super.intrinsicContentSize
        guard let image = imageView?.image else { return super.intrinsicContentSize }
        let size = titleLabel?.sizeThatFits(contentRect(forBounds: bounds).size) ?? .zero
        let spacing: CGFloat = 12
        return CGSize(width: max(size.width, image.size.width), height: image.size.height + size.height + spacing)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        titleLabel?.textAlignment = .center
    }
}

The solution above may not work in iOS 12 and older. In this case create constants with fixed image size and label height depending on font size.

final class CustomButton: UIButton {

    private enum Constants {
        static let imageSize: CGFloat = 48
        static let titleHeight = yourFont.pointSize - yourFont.descender
    }

    override func titleRect(forContentRect contentRect: CGRect) -> CGRect {
        _ = super.titleRect(forContentRect: contentRect)
        return CGRect(
            x: 0,
            y: contentRect.height - Constants.titleHeight,
            width: contentRect.width,
            height: Constants.titleHeight
        )
    }

    override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
        return CGRect(
            x: contentRect.width / 2 - Constants.imageSize / 2,
            y: (contentRect.height - titleRect(forContentRect: contentRect).height) / 2 - Constants.imageSize / 2,
            width: Constants.imageSize,
            height: Constants.imageSize
        )
    }

    override var intrinsicContentSize: CGSize {
        _ = super.intrinsicContentSize
        let size = titleLabel?.sizeThatFits(contentRect(forBounds: bounds).size) ?? .zero
        let spacing: CGFloat = 12
        return CGSize(
            width: max(size.width, Constants.imageSize),
            height: Constants.imageSize + Constants.titleHeight + spacing
        )
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        titleLabel?.textAlignment = .center
    }
}
Robert Dresler
  • 10,580
  • 2
  • 22
  • 40
6

Using the code of Kenny Winker's and simeon i make this swift code that works for me.

import UIKit

@IBDesignable
class TopIconButton: UIButton {
    override func layoutSubviews() {
        super.layoutSubviews()

        let kTextTopPadding:CGFloat = 3.0;

        var titleLabelFrame = self.titleLabel!.frame;


        let labelSize = titleLabel!.sizeThatFits(CGSizeMake(CGRectGetWidth(self.contentRectForBounds(self.bounds)), CGFloat.max))

        var imageFrame = self.imageView!.frame;

        let fitBoxSize = CGSizeMake(max(imageFrame.size.width, labelSize.width), labelSize.height + kTextTopPadding + imageFrame.size.    height)

        let fitBoxRect = CGRectInset(self.bounds, (self.bounds.size.width - fitBoxSize.width)/2, (self.bounds.size.height - fitBoxSize.    height)/2);

        imageFrame.origin.y = fitBoxRect.origin.y;
        imageFrame.origin.x = CGRectGetMidX(fitBoxRect) - (imageFrame.size.width/2);
        self.imageView!.frame = imageFrame;

        // Adjust the label size to fit the text, and move it below the image

        titleLabelFrame.size.width = labelSize.width;
        titleLabelFrame.size.height = labelSize.height;
        titleLabelFrame.origin.x = (self.frame.size.width / 2) - (labelSize.width / 2);
        titleLabelFrame.origin.y = fitBoxRect.origin.y + imageFrame.size.height + kTextTopPadding;
        self.titleLabel!.frame = titleLabelFrame;
        self.titleLabel!.textAlignment = .Center
    }

}
5

@Tiago I change your answer like this. Its works fine with all sizes

func alignImageAndTitleVertically(padding: CGFloat = 5.0) {
        self.sizeToFit()
        let imageSize = self.imageView!.frame.size
        let titleSize = self.titleLabel!.frame.size
        let totalHeight = imageSize.height + titleSize.height + padding

        self.imageEdgeInsets = UIEdgeInsets(
            top: -(totalHeight - imageSize.height),
            left: 0,
            bottom: 0,
            right: -titleSize.width
        )

        self.titleEdgeInsets = UIEdgeInsets(
            top: 0,
            left: 0,
            bottom: -(totalHeight - titleSize.height),
            right: titleSize.height
        )
    }
mychar
  • 1,031
  • 1
  • 11
  • 20
5

Localization Friendly Solution:

So many great solutions guys, but I'd like to add a note here for those who use localization.

You need to reverse the left and right EdgeInstets values to get the button laid out correctly in case of a change of language direction from LtR to RtL.

Using a similar solution I'd implement it as follows:

extension UIButton {

    func alignVertical(spacing: CGFloat, lang: String) {
        guard let imageSize = self.imageView?.image?.size,
            let text = self.titleLabel?.text,
            let font = self.titleLabel?.font
        else { return }

        let labelString = NSString(string: text)
        let titleSize = labelString.size(
            withAttributes: [NSAttributedString.Key.font: font]
        )

        var titleLeftInset: CGFloat = -imageSize.width
        var titleRigtInset: CGFloat = 0.0

        var imageLeftInset: CGFloat = 0.0
        var imageRightInset: CGFloat = -titleSize.width

        if Locale.current.languageCode! != "en" { // If not Left to Right language
            titleLeftInset = 0.0
            titleRigtInset = -imageSize.width

            imageLeftInset = -titleSize.width
            imageRightInset = 0.0
        }

        self.titleEdgeInsets = UIEdgeInsets(
            top: 0.0,
            left: titleLeftInset,
            bottom: -(imageSize.height + spacing),
            right: titleRigtInset
        )
        self.imageEdgeInsets = UIEdgeInsets(
            top: -(titleSize.height + spacing),
            left: imageLeftInset,
            bottom: 0.0,
            right: imageRightInset
        )
        let edgeOffset = abs(titleSize.height - imageSize.height) / 2.0;
        self.contentEdgeInsets = UIEdgeInsets(
            top: edgeOffset,
            left: 0.0,
            bottom: edgeOffset,
            right: 0.0
        )
    }
}

Wissa
  • 1,444
  • 20
  • 24
3

You just have to adjust all three edge insets based on the size of your image and title label:

button.contentEdgeInsets = UIEdgeInsetsMake(0, 0, titleLabelBounds.height + 4, 0)
button.titleEdgeInsets = UIEdgeInsetsMake(image.size.height + 8, -image.size.width, 0, 0)
button.imageEdgeInsets = UIEdgeInsetsMake(0, 0, 0, -titleLabelBounds.width)

You can get the title label bounds by calling sizeToFit after setting its text. The horizontal spacing should work regardless of the size of the text, font and image but I don't know of a single solution to get the vertical spacing and bottom content edge inset consistent.

Justin Driscoll
  • 654
  • 4
  • 10
3

Here is "Bear With Me"s answer as a subclass in Swift 2.0. To use it just change your button class in Interface Builder to VerticalButton and it will magically update the preview.

I also updated it to calculate the correct intrinsic content size for autolayout.

import UIKit

@IBDesignable

class VerticalButton: UIButton {
    @IBInspectable var padding: CGFloat = 8

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()

        update()
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        update()
    }

    func update() {
        let imageBounds = self.imageView!.bounds
        let titleBounds = self.titleLabel!.bounds
        let totalHeight = CGRectGetHeight(imageBounds) + padding + CGRectGetHeight(titleBounds)

        self.imageEdgeInsets = UIEdgeInsets(
            top: -(totalHeight - CGRectGetHeight(imageBounds)),
            left: 0,
            bottom: 0,
            right: -CGRectGetWidth(titleBounds)
        )

        self.titleEdgeInsets = UIEdgeInsets(
            top: 0,
            left: -CGRectGetWidth(imageBounds),
            bottom: -(totalHeight - CGRectGetHeight(titleBounds)),
            right: 0
        )
    }

    override func intrinsicContentSize() -> CGSize {
        let imageBounds = self.imageView!.bounds
        let titleBounds = self.titleLabel!.bounds

        let width = CGRectGetWidth(imageBounds) > CGRectGetWidth(titleBounds) ? CGRectGetWidth(imageBounds) : CGRectGetWidth(titleBounds)
        let height = CGRectGetHeight(imageBounds) + padding + CGRectGetHeight(titleBounds)

        return CGSizeMake(width, height)
    }
}
Maciej Swic
  • 11,139
  • 8
  • 52
  • 68
  • 2
    Ends up as an infinite loop where `layoutSubviews()` is called repeatedly in my case: `intrinsicContentSize` accesses `imageView` which makes `layoutSubviews` being called which accesses `imageView` etc. – ctietze May 16 '16 at 07:44
3

I took a combination of the answers here and came up with one that seems to be working for me, in Swift. I don't love how I just overrode the insets, but it works. I'd be open to suggested improvements in the comments. It seems to work correctly with sizeToFit() and with auto layout.

import UIKit

/// A button that displays an image centered above the title.  This implementation 
/// only works when both an image and title are set, and ignores
/// any changes you make to edge insets.
class CenteredButton: UIButton
{
    let padding: CGFloat = 0.0

    override func layoutSubviews() {
        if imageView?.image != nil && titleLabel?.text != nil {
            let imageSize: CGSize = imageView!.image!.size
            titleEdgeInsets = UIEdgeInsetsMake(0.0, -imageSize.width, -(imageSize.height + padding), 0.0)
            let labelString = NSString(string: titleLabel!.text!)
            let titleSize = labelString.sizeWithAttributes([NSFontAttributeName: titleLabel!.font])
            imageEdgeInsets = UIEdgeInsetsMake(-(titleSize.height + padding), 0.0, 0.0, -titleSize.width)
            let edgeOffset = abs(titleSize.height - imageSize.height) / 2.0;
            contentEdgeInsets = UIEdgeInsetsMake(edgeOffset, 0.0, edgeOffset, 0.0)
        }
        super.layoutSubviews()
    }

    override func sizeThatFits(size: CGSize) -> CGSize {
        let defaultSize = super.sizeThatFits(size)
        if let titleSize = titleLabel?.sizeThatFits(size),
        let imageSize = imageView?.sizeThatFits(size) {
            return CGSize(width: ceil(max(imageSize.width, titleSize.width)), height: ceil(imageSize.height + titleSize.height + padding))
        }
        return defaultSize
    }

    override func intrinsicContentSize() -> CGSize {
        let size = sizeThatFits(CGSize(width: CGFloat.max, height: CGFloat.max))
        return size
    }
}
icecrystal23
  • 331
  • 3
  • 10
3

Use this two methods:

func titleRect(forContentRect contentRect: CGRect) -> CGRect
func imageRect(forContentRect contentRect: CGRect) -> CGRect

Example:

class VerticalButton: UIButton {

  override func titleRect(forContentRect contentRect: CGRect) -> CGRect {
    let titleRect = super.titleRect(forContentRect: contentRect)
    let imageRect = super.imageRect(forContentRect: contentRect)

    return CGRect(x: 0,
                  y: contentRect.height - (contentRect.height - padding - imageRect.size.height - titleRect.size.height) / 2 - titleRect.size.height,
                  width: contentRect.width,
                  height: titleRect.height)
  }

  override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
    let imageRect = super.imageRect(forContentRect: contentRect)
    let titleRect = self.titleRect(forContentRect: contentRect)

    return CGRect(x: contentRect.width/2.0 - imageRect.width/2.0,
                  y: (contentRect.height - padding - imageRect.size.height - titleRect.size.height) / 2,
                  width: imageRect.width,
                  height: imageRect.height)
  }

  private let padding: CGFloat
  init(padding: CGFloat) {
    self.padding = padding

    super.init(frame: .zero)
    self.titleLabel?.textAlignment = .center
  }

  required init?(coder aDecoder: NSCoder) { fatalError() }
}

extension UIButton {

  static func vertical(padding: CGFloat) -> UIButton {
    return VerticalButton(padding: padding)
  }
}

And you can use:

let myButton = UIButton.vertical(padding: 6)
ober
  • 2,363
  • 1
  • 19
  • 17
3

That's definitely is an overkill for this question, however... In one of my projects I first had to implement a button with icon aligned leftmost. Then we got another button with title under image. I searched for an existing solution but with no luck So, here goes the alignable button:

@IBDesignable
class AlignableButton: UIButton {

override class var requiresConstraintBasedLayout: Bool {
    return true
}

@objc enum IconAlignment: Int {
    case top, left, right, bottom
}

// MARK: - Designables
@IBInspectable var iconAlignmentValue: Int {
    set {
        iconAlignment = IconAlignment(rawValue: newValue) ?? .left
    }
    get {
        return iconAlignment.rawValue
    }
}

var iconAlignment: IconAlignment = .left

@IBInspectable var titleAlignmentValue: Int {
    set {
        titleAlignment = NSTextAlignment(rawValue: newValue) ?? .left
    }
    get {
        return titleAlignment.rawValue
    }
}

var titleAlignment: NSTextAlignment = .left

// MARK: - Corner Radius
@IBInspectable
var cornerRadius: CGFloat {
    get {
        return layer.cornerRadius
    }
    set {
        layer.masksToBounds = (newValue != 0)
        layer.cornerRadius = newValue
    }
}

// MARK: - Content size
override var intrinsicContentSize: CGSize {
    
    switch iconAlignment {
    case .top, .bottom:
        return verticalAlignedIntrinsicContentSize
    
    default:
        return super.intrinsicContentSize
    }
}

private var verticalAlignedIntrinsicContentSize: CGSize {
    
    if let imageSize = imageView?.intrinsicContentSize,
        let labelSize = titleLabel?.intrinsicContentSize {
        
        let width = max(imageSize.width, labelSize.width) + contentEdgeInsets.left + contentEdgeInsets.right
        let height = imageSize.height + labelSize.height + contentEdgeInsets.top + contentEdgeInsets.bottom
        
        return CGSize(
            width: ceil(width),
            height: ceil(height)
        )
    }
    
    return super.intrinsicContentSize
}

// MARK: - Image Rect
override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
    
    switch iconAlignment {
    case .top:
        return topAlignedImageRect(forContentRect: contentRect)
    case .bottom:
        return bottomAlignedImageRect(forContentRect: contentRect)
    case .left:
        return leftAlignedImageRect(forContentRect: contentRect)
    case .right:
        return rightAlignedImageRect(forContentRect: contentRect)
    }
}

func topAlignedImageRect(forContentRect contentRect: CGRect) -> CGRect {
    let rect = super.imageRect(forContentRect: contentRect)
    
    let x = (contentRect.width - rect.width) / 2.0 + contentRect.minX
    let y = contentRect.minY
    let w = rect.width
    let h = rect.height
    
    return CGRect(
        x: x,
        y: y,
        width: w,
        height: h
    ).inset(by: imageEdgeInsets)
}

func bottomAlignedImageRect(forContentRect contentRect: CGRect) -> CGRect {
    let rect = super.imageRect(forContentRect: contentRect)
    
    let x = (contentRect.width - rect.width) / 2.0 + contentRect.minX
    let y = contentRect.height - rect.height + contentRect.minY
    let w = rect.width
    let h = rect.height
    
    return CGRect(
        x: x,
        y: y,
        width: w,
        height: h
    ).inset(by: imageEdgeInsets)
}

func leftAlignedImageRect(forContentRect contentRect: CGRect) -> CGRect {
    let rect = super.imageRect(forContentRect: contentRect)
    
    let x = contentRect.minX
    let y = (contentRect.height - rect.height) / 2 + contentRect.minY
    let w = rect.width
    let h = rect.height
    
    return CGRect(
        x: x,
        y: y,
        width: w,
        height: h
    ).inset(by: imageEdgeInsets)
}

func rightAlignedImageRect(forContentRect contentRect: CGRect) -> CGRect {
    let rect = super.imageRect(forContentRect: contentRect)
    
    let x = (contentRect.width - rect.width) + contentRect.minX
    let y = (contentRect.height - rect.height) / 2 + contentRect.minY
    let w = rect.width
    let h = rect.height
    
    return CGRect(
        x: x,
        y: y,
        width: w,
        height: h
    ).inset(by: imageEdgeInsets)
}

// MARK: - Title Rect
override func titleRect(forContentRect contentRect: CGRect) -> CGRect {
    
    switch iconAlignment {
    case .top:
        return topAlignedTitleRect(forContentRect: contentRect)
    case .bottom:
        return bottomAlignedTitleRect(forContentRect: contentRect)
    case .left:
        return leftAlignedTitleRect(forContentRect: contentRect)
    case .right:
        return rightAlignedTitleRect(forContentRect: contentRect)
    }
}

func topAlignedTitleRect(forContentRect contentRect: CGRect) -> CGRect {
    
    let rect = super.titleRect(forContentRect: contentRect)

    let x = contentRect.minX
    let y = contentRect.height - rect.height + contentRect.minY
    let w = contentRect.width
    let h = rect.height
    
    return CGRect(
        x: x,
        y: y,
        width: w,
        height: h
    ).inset(by: titleEdgeInsets)
}

func bottomAlignedTitleRect(forContentRect contentRect: CGRect) -> CGRect {
    
    let rect = super.titleRect(forContentRect: contentRect)
    
    let x = contentRect.minX
    let y = contentRect.minY
    let w = contentRect.width
    let h = rect.height
    
    return CGRect(
        x: x,
        y: y,
        width: w,
        height: h
    ).inset(by: titleEdgeInsets)
}

func leftAlignedTitleRect(forContentRect contentRect: CGRect) -> CGRect {
    
    let titleRect = super.titleRect(forContentRect: contentRect)
    let imageRect = self.imageRect(forContentRect: contentRect)
    
    let x = imageRect.width + imageRect.minX
    let y = (contentRect.height - titleRect.height) / 2.0 + contentRect.minY
    let w = contentRect.width - imageRect.width * 2.0
    let h = titleRect.height
    
    return CGRect(
        x: x,
        y: y,
        width: w,
        height: h
    ).inset(by: titleEdgeInsets)
}

func rightAlignedTitleRect(forContentRect contentRect: CGRect) -> CGRect {
    
    let titleRect = super.titleRect(forContentRect: contentRect)
    let imageRect = self.imageRect(forContentRect: contentRect)

    let x = contentRect.minX + imageRect.width
    let y = (contentRect.height - titleRect.height) / 2.0 + contentRect.minY
    let w = contentRect.width - imageRect.width * 2.0
    let h = titleRect.height
    
    return CGRect(
        x: x,
        y: y,
        width: w,
        height: h
    ).inset(by: titleEdgeInsets)
}

// MARK: - Lifecycle
override func awakeFromNib() {
    super.awakeFromNib()
    
    titleLabel?.textAlignment = titleAlignment
}

override func prepareForInterfaceBuilder() {
    super.prepareForInterfaceBuilder()
    
    titleLabel?.textAlignment = titleAlignment
}
}

Hope you find it useful.

eagle.dan.1349
  • 611
  • 1
  • 8
  • 20
3

Using @Robert Dresler solution wasn't working for me on IOS 15, so I extended the button class by adding a condition to check if is IOS 15 and use the new ui button configuration for IOS 15

final class ImageButton: UIButton {

    private enum Constants {
        static let imageSize: CGFloat = 40
        static let titleHeight: CGFloat = 12
    }

    override func titleRect(forContentRect contentRect: CGRect) -> CGRect {
        if #available(iOS 15, *) {
           return super.titleRect(forContentRect: contentRect)
        }
        else {
            _ = super.titleRect(forContentRect: contentRect)
            return CGRect(
                x: 0,
                y: contentRect.height - Constants.titleHeight,
                width: contentRect.width,
                height: Constants.titleHeight
            )
        }
    }

    override func imageRect(forContentRect contentRect: CGRect) -> CGRect {
        if #available(iOS 15, *) {
           return super.imageRect(forContentRect: contentRect)
        } else {
            return CGRect(
                x: contentRect.width / 2 - Constants.imageSize / 2,
                y: (contentRect.height - titleRect(forContentRect: contentRect).height) / 2 - Constants.imageSize / 2,
                width: Constants.imageSize,
                height: Constants.imageSize
            )
        }
    }

    override var intrinsicContentSize: CGSize {
        if #available(iOS 15, *) {
           return super.intrinsicContentSize
        }
        else {
            _ = super.intrinsicContentSize
            let size = titleLabel?.sizeThatFits(contentRect(forBounds: bounds).size) ?? .zero
            let spacing: CGFloat = 12
            return CGSize(
                width: max(size.width, Constants.imageSize),
                height: Constants.imageSize + Constants.titleHeight + spacing
            )
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        if #available(iOS 15, *) {
            var myConfiguration = UIButton.Configuration.plain()
            myConfiguration.imagePlacement = .top
            self.configuration = myConfiguration
        } else {
            titleLabel?.textAlignment = .center
        }
    }
}

This solution works on IOS 12 to 15

Here's a snapshot of the final result

Result

Shez Ratnani
  • 312
  • 3
  • 15
  • 2
    Thanks, this was great. Note, I added the inline var spacing to Constants, as this was also relevant for iOS 15 code: myConfiguration.imagePadding = Constants.spacing – horseshoe7 Feb 25 '22 at 15:58
2

I think one of the best ways to do that is by subclassing UIButton and override some rendering methods:

- (void)awakeFromNib
{
    [super awakeFromNib];
    [self setupSubViews];
}

- (instancetype)init
{
    if (self = [super init])
    {
        [self setupSubViews];
    }
    return self;
}

- (void)setupSubViews
{
    [self.imageView setTranslatesAutoresizingMaskIntoConstraints:NO];
    [self addConstraint:[NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:self.imageView attribute:NSLayoutAttributeCenterX multiplier:1 constant:0]];
    [self.titleLabel setTranslatesAutoresizingMaskIntoConstraints:NO];
    [self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[imageView][titleLabel]|" options:NSLayoutFormatAlignAllCenterX metrics:nil views:@{@"imageView": self.imageView, @"titleLabel": self.titleLabel}]];
}

- (CGSize)intrinsicContentSize
{
    CGSize imageSize = self.imageView.image.size;
    CGSize titleSize = [self.titleLabel.text sizeWithAttributes:@{NSFontAttributeName: self.titleLabel.font}];
    return CGSizeMake(MAX(imageSize.width, titleSize.width), imageSize.height + titleSize.height);
}
Basem Saadawy
  • 1,808
  • 2
  • 20
  • 30
2

I found that Simeon's answer was probably the best but it was giving me strange results on some buttons and I just couldn't work out why. So using his answer as a base I implemented my buttons as per below:

#define PADDING 2.0f

@implementation OOButtonVerticalImageText

-(CGSize) intrinsicContentSize {
  CGSize size = [super intrinsicContentSize];
  CGFloat labelHeight = 0.0f;
  CGSize titleSize = [self.titleLabel sizeThatFits:CGSizeMake([self contentRectForBounds:self.bounds].size.width, CGFLOAT_MAX)];
  labelHeight = titleSize.height;
  return CGSizeMake(MAX(titleSize.width, self.imageView.image.size.width), self.imageView.image.size.height + labelHeight + PADDING);
}

-(void) layoutSubviews {
  [super layoutSubviews];

  CGSize titleSize = [self.titleLabel sizeThatFits:CGSizeMake([self contentRectForBounds:self.bounds].size.width, CGFLOAT_MAX)];
  self.titleLabel.frame = CGRectMake((self.bounds.size.width - titleSize.width)/2.0f,
                                     self.bounds.size.height - titleSize.height - PADDING,
                                     titleSize.width,
                                     titleSize.height);

  CGSize ivSize = self.imageView.frame.size;
  self.imageView.frame = CGRectMake((self.bounds.size.width - ivSize.width)/2.0f,
                                    self.titleLabel.frame.origin.y - ivSize.height - PADDING,
                                    ivSize.width,
                                    ivSize.height);
}

@end
Mof
  • 363
  • 3
  • 9
1

Here is my subclass of UIButton which solves this problem:

@implementation MyVerticalButton

@synthesize titleAtBottom; // BOOL property

- (id)initWithFrame:(CGRect)frame
{
  self = [super initWithFrame:frame];
  if (self) {
    self.titleAtBottom = YES;
  }
  return self;
}

- (CGSize)sizeThatFits:(CGSize)size {
  self.titleLabel.text = [self titleForState: self.state];

  UIEdgeInsets imageInsets = self.imageEdgeInsets;
  UIEdgeInsets titleInsets = self.titleEdgeInsets;

  CGSize imageSize = [self imageForState: self.state].size;
  if (!CGSizeEqualToSize(imageSize, CGSizeZero)) {
    imageSize.width += imageInsets.left + imageInsets.right;
    imageSize.height += imageInsets.top + imageInsets.bottom;

  }

  CGSize textSize = [self.titleLabel sizeThatFits: CGSizeMake(size.width - titleInsets.left - titleInsets.right,
                                                              size.height -(imageSize.width +
                                                                            titleInsets.top+titleInsets.bottom))];
  if (!CGSizeEqualToSize(textSize, CGSizeZero)) {
    textSize.width += titleInsets.left + titleInsets.right;
    textSize.height += titleInsets.top + titleInsets.bottom;
  }

  CGSize result = CGSizeMake(MAX(textSize.width, imageSize.width),
                             textSize.height + imageSize.height);
  return result;
}

- (void)layoutSubviews {
  // needed to update all properities of child views:
  [super layoutSubviews];

  CGRect bounds = self.bounds;

  CGRect titleFrame = UIEdgeInsetsInsetRect(bounds, self.titleEdgeInsets);
  CGRect imageFrame = UIEdgeInsetsInsetRect(bounds, self.imageEdgeInsets);
  if (self.titleAtBottom) {
    CGFloat titleHeight = [self.titleLabel sizeThatFits: titleFrame.size].height;
    titleFrame.origin.y = CGRectGetMaxY(titleFrame)-titleHeight;
    titleFrame.size.height = titleHeight;
    titleFrame = CGRectStandardize(titleFrame);
    self.titleLabel.frame = titleFrame;

    CGFloat imageBottom = CGRectGetMinY(titleFrame)-(self.titleEdgeInsets.top+self.imageEdgeInsets.bottom);
    imageFrame.size.height = imageBottom - CGRectGetMinY(imageFrame);
    self.imageView.frame = CGRectStandardize(imageFrame);
  } else {
    CGFloat titleHeight = [self.titleLabel sizeThatFits: titleFrame.size].height;
    titleFrame.size.height = titleHeight;
    titleFrame = CGRectStandardize(titleFrame);
    self.titleLabel.frame = titleFrame;

    CGFloat imageTop = CGRectGetMaxY(titleFrame)+(self.titleEdgeInsets.bottom+self.imageEdgeInsets.top);
    imageFrame.size.height = CGRectGetMaxY(imageFrame) - imageTop;
    self.imageView.frame = CGRectStandardize(imageFrame);
  }
}

- (void)setTitleAtBottom:(BOOL)newTitleAtBottom {
  if (titleAtBottom!=newTitleAtBottom) {
    titleAtBottom=newTitleAtBottom;
    [self setNeedsLayout];
  }
}

@end

That's it. Works like charm. Problem may appear if button will be to small to fit title and text.

Marek R
  • 32,568
  • 6
  • 55
  • 140
  • It does work like a charm indeed! And with auto layout too. Thanks a lot for sharing this solution. I was going nuts with this and resorting to create my own UIControl subclass. – valeCocoa Sep 25 '17 at 19:42
1

@simeon's solution in Objective-C

#import "CenteredButton.h"

@implementation CenteredButton

- (CGRect)titleRectForContentRect:(CGRect)contentRect
{
    CGRect rect = [super titleRectForContentRect: contentRect];
    return CGRectMake(0,
                      contentRect.size.height - rect.size.height - 5,
                      contentRect.size.width,
                      rect.size.height);
}

- (CGRect)imageRectForContentRect:(CGRect)contentRect
{
    CGRect rect = [super imageRectForContentRect: contentRect];
    CGRect titleRect = [self titleRectForContentRect: contentRect];

    return CGRectMake(contentRect.size.width / 2.0 - rect.size.width / 2.0,
                      (contentRect.size.height - titleRect.size.height)/2.0 - rect.size.height/2.0,
                      rect.size.width,
                      rect.size.height);
}

- (CGSize)intrinsicContentSize {
    CGSize imageSize = [super intrinsicContentSize];

    if (self.imageView.image) {
        UIImage* image = self.imageView.image;
        CGFloat labelHeight = 0.0;

        CGSize labelSize = [self.titleLabel sizeThatFits: CGSizeMake([self contentRectForBounds: self.bounds].size.width, CGFLOAT_MAX)];
        if (CGSizeEqualToSize(imageSize, labelSize)) {
            labelHeight = imageSize.height;
        }

        return CGSizeMake(MAX(labelSize.width, imageSize.width), image.size.height + labelHeight + 5);
    }

    return imageSize;
}

- (id) initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
     if (self) {
         [self centerTitleLabel];
     }
    return self;

}

- (id)initWithCoder:(NSCoder *)aDecoder {
    self = [super initWithCoder:aDecoder];
    if (self) {
        [self centerTitleLabel];
    }
    return self;
}

- (void)centerTitleLabel {
    self.titleLabel.textAlignment = NSTextAlignmentCenter;
}

@end
Will
  • 426
  • 4
  • 7
  • I think the intrinsicContentSize is not correct here. I don't understand what the part with the CGSizeEqualToSize is for but you only have a label size > 0 if the label size matches the intrinsicContentSize of UILabel. It should be sufficient to just return `CGSizeMake(MAX(labelSize.width, image.size.width), image.size.height + labelSize.height + 5.0)` in the if-case – Oliver Jun 28 '17 at 06:32
1

If you are using custom fonts the calculation for the titleLabel size won't work properly, you should replace it with

let titleLabelSize = self.titleLabel?.text?.size(withAttributes: [NSAttributedStringKey.font: self.titleLabel!.font!])

Benjamin Jimenez
  • 984
  • 12
  • 26
1

Instead of going through hell trying to position the icon and the text with edge insets, you could create a NSAttributedString with your image as an attachment and set it to your button's attributed title instead:

let titleText = NSAttributedString(string: yourTitle, attributes: attributes)
let imageAttachment = NSTextAttachment()
imageAttachment.image = yourImage

let title = NSMutableAttributedString(string: "")
title.append(NSAttributedString(attachment: imageAttachment))
title.append(titleText)

button.setAttributedTitle(title, for: .normal)
swearwolf
  • 327
  • 3
  • 10
  • Doesn't work for the OPs question where the text should be centered *below* the image. A `UIButton`'s text field layouts to display only 1 line, hence it doesn't work even when using a line break in the attributed string. Would be a nice solution otherwise. – Manuel Nov 27 '18 at 19:11
  • It is also important to set `button.titleLabel?.numberOfLines` in order to get the needed number of lines – swearwolf Nov 28 '18 at 20:09
0

Something like this inside UIButton subclass

public override func layoutSubviews() {
    super.layoutSubviews()

    imageEdgeInsets = UIEdgeInsetsMake(-10, 0, 0, 0)
    titleEdgeInsets = UIEdgeInsetsMake(0, -bounds.size.width/2 - 10, -30, 0)
}
onmyway133
  • 45,645
  • 31
  • 257
  • 263
0

iOS 11 - Objective-C

-(void)layoutSubviews {
[super layoutSubviews];

CGRect titleLabelFrame = self.titleLabel.frame;
CGSize labelSize = [self.titleLabel.text sizeWithAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:12.0f]}];
CGSize adjustedLabelSize = CGSizeMake(ceilf(labelSize.width), ceilf(labelSize.height));

CGRect imageFrame = self.imageView.frame;

CGSize fitBoxSize = (CGSize){.height = adjustedLabelSize.height + kTextTopPadding +  imageFrame.size.height, .width = MAX(imageFrame.size.width, adjustedLabelSize.width)};

CGRect fitBoxRect = CGRectInset(self.bounds, (self.bounds.size.width - fitBoxSize.width)/2, (self.bounds.size.height - fitBoxSize.height)/2);

imageFrame.origin.y = fitBoxRect.origin.y;
imageFrame.origin.x = CGRectGetMidX(fitBoxRect) - (imageFrame.size.width/2);
self.imageView.frame = imageFrame;

// Adjust the label size to fit the text, and move it below the image

titleLabelFrame.size.width = adjustedLabelSize.width;
titleLabelFrame.size.height = adjustedLabelSize.height;
titleLabelFrame.origin.x = (self.frame.size.width / 2) - (adjustedLabelSize.width / 2);
titleLabelFrame.origin.y = fitBoxRect.origin.y + imageFrame.size.height + kTextTopPadding;
self.titleLabel.frame = titleLabelFrame; }
Brandon Stillitano
  • 1,304
  • 8
  • 27
0

Top image and bottom title button with subclassing UIButton

class VerticalButton: UIButton {
  override func layoutSubviews() {
    super.layoutSubviews()
    let padding: CGFloat = 8
    let iH = imageView?.frame.height ?? 0
    let tH = titleLabel?.frame.height ?? 0
    let v: CGFloat = (frame.height - iH - tH - padding) / 2
    if let iv = imageView {
      let x = (frame.width - iv.frame.width) / 2
      iv.frame.origin.y = v
      iv.frame.origin.x = x
    }

    if let tl = titleLabel {
      let x = (frame.width - tl.frame.width) / 2
      tl.frame.origin.y = frame.height - tl.frame.height - v
      tl.frame.origin.x = x
    }
  }
}
Shohin
  • 519
  • 8
  • 11
0

Another way I found:

UIButton has 2 UIImageViews. First of them we can reach using UIButton's imageView property. Another UIImage view is inaccessible via UIButton's properties, but that another UIImageView perfectly fits to implement centered button. To access that background image view I added an extension:

extension UIButton {

    var backgroundImageView: UIImageView? {
        return subviews.first(where: { $0.isKind(of: UIImageView.self) && $0 != imageView }) as? UIImageView
    }
}

The next step is to setup the button:

centeredButton.setBackgroundImage(perfectImage, for: .normal) // This sets image to Button's backgroundImageView
centeredButton.backgroundImageView?.contentMode = .top // This moves image to the top of the Button's frame
centeredButton.contentVerticalAlignment = .bottom // This moves label to the bottom of the Button's frame.

The next step is to set padding between image and label. You can make this by adding height constraint to the button. The image will alway be on the top, label on the bottom.

Dmitry
  • 2,963
  • 2
  • 21
  • 39
0

Storyboard Friendly Solution

Here's a way to do it which allows insets to be further adjusted in the storyboard:

@IBDesignable
class RotatableButton: UIButton {
    @IBInspectable public var rotate: Bool = false {
       didSet { if (rotate) { Rotate() } }
    }

    func Rotate() {
        self.titleLabel?.transform = CGAffineTransform(rotationAngle: CGFloat(-CGFloat.pi/2))
        self.transform = CGAffineTransform(rotationAngle: CGFloat(CGFloat.pi/2))
        // Hacky workaround text clipping
        self.titleLabel?.lineBreakMode = .byWordWrapping
        self.setTitle((self.titleLabel?.text)! + "\n\n", for: .normal)
    }
}
-1

Its pretty simple.

Instead of this:

   button.setImage(UIImage(named: "image"), forState: .Normal)

Use this:

   button.setBackgroundImage(UIImage(named: "image", forState: .Normal)

Then you can add text on the button easily using:

// button.titleLabel!.font = UIFont(name: "FontName", size: 30)

 button.setTitle("TitleText", forState: UIControlState.Normal)