Back to the UIStackView: The tale of the incredible shrinking UIButton and how it grew again (Xcode 7.2, iOS 9; updated Swift 3, Xcode 8)


A solitary button, all alone in the world

Create a UIButton and set the title text and colour:
let button = UIButton(frame: CGRect(x: 20, y: 20, width: 100, height: 50))
button.setTitle("My Button", forState: .Normal)
button.setTitleColor(.blueColor(), forState: .Normal)
button.backgroundColor = .yellowColor()
self.view.addSubview(button)

Swift 3, Xcode 8

let button = UIButton(frame: CGRect(x: 20, y: 20, width: 100, height: 50))
button.setTitle("My Button", for: .normal)
button.setTitleColor(.blue, for: .normal)
button.backgroundColor = .yellow
self.view.addSubview(button)

... where the poor little button meets the big bad stack

The above button should render as expected within a regular view, but now try adding that button to a UIStackView by first creating an extension
extension UIStackView {
    
    convenience init(axis:UILayoutConstraintAxis, spacing:CGFloat) {
        self.init()
        self.axis = axis
        self.spacing = spacing
        self.translatesAutoresizingMaskIntoConstraints = false
    }
    
    func anchorStackView(toView view:UIView, anchorX:NSLayoutXAxisAnchor, equalAnchorX:NSLayoutXAxisAnchor, anchorY:NSLayoutYAxisAnchor, equalAnchorY:NSLayoutYAxisAnchor) {
        view.addSubview(self)
        anchorX.constraintEqualToAnchor(equalAnchorX).active = true
        anchorY.constraintEqualToAnchor(equalAnchorY).active = true
        
    }   
} 

Swift 4.1

extension UIStackView {
    
    convenience init(axis:UILayoutConstraintAxis, spacing:CGFloat) {
        self.init()
        self.axis = axis
        self.spacing = spacing
        self.translatesAutoresizingMaskIntoConstraints = false
    }
    
    func anchorStackView(toView view:UIView, anchorX:NSLayoutXAxisAnchor, equalAnchorX:NSLayoutXAxisAnchor, anchorY:NSLayoutYAxisAnchor, equalAnchorY:NSLayoutYAxisAnchor) {
        view.addSubview(self)
        anchorX.constraint(equalTo: equalAnchorX).isActive = true
        anchorY.constraint(equalTo: equalAnchorY).isActive = true
        
    } 
}
and then utilising it using the following code:
let stack = UIStackView(axis: .Vertical, spacing: 10)
stack.anchorStackView(toView: view, anchorX: stack.centerXAnchor, equalAnchorX: view.centerXAnchor, anchorY: stack.centerYAnchor, equalAnchorY: view.centerYAnchor)

let button = UIButton(frame: CGRect(x: 20, y: 20, width: 100, height: 50))
button.setTitle("My Button", forState: .Normal)
button.setTitleColor(.blueColor(), forState: .Normal)
button.backgroundColor = .yellowColor()
        
stack.addArrangedSubview(button)
Implementing this code you'll notice that while the text remains as before that the background size has shrunk to the same area as the text. This is because a UIStackView treats the space around the label text, as it does a UIView, as something that is flexible and can be contorted however it sees fit.

Time for the little button to spread its wings and grow

In order to address this I'll implement here constraints that ensure the button is the size required:
extension UIButton {
    convenience init(title:String, titleColor:UIColor, target:UIViewController, selector:Selector, buttonHeight:CGFloat, buttonWidth:CGFloat, backgroundColor:UIColor) {
        self.init()
        self.setTitle(title, forState: .Normal)
        self.setTitleColor(titleColor, forState: .Normal)
        self.addTarget(target, action: selector, forControlEvents: .TouchDown)
        self.backgroundColor = backgroundColor
        self.heightAnchor.constraintGreaterThanOrEqualToConstant(buttonHeight).active = true
        self.widthAnchor.constraintGreaterThanOrEqualToConstant(buttonWidth).active = true
    }
}

Swift 4.1

extension UIButton {
    convenience init(title:String, titleColor:UIColor, target:UIViewController, selector:Selector, buttonHeight:CGFloat, buttonWidth:CGFloat, backgroundColor:UIColor) {
        self.init()
        self.setTitle(title, for: .normal)
        self.setTitleColor(titleColor, for: .normal)
        self.addTarget(target, action: selector, for: .touchDown)
        self.backgroundColor = backgroundColor
       self.heightAnchor.constraint(greaterThanOrEqualToConstant: buttonHeight).isActive = true
        self.widthAnchor.constraint(greaterThanOrEqualToConstant: buttonWidth).isActive = true
    }
}
The magic happens in the final two lines of the above code where the constraints are added and activated. Now all behaves as expected when implemented in the following way:
let stack = UIStackView(axis: .Vertical, spacing: 10)
stack.anchorStackView(toView: view, anchorX: stack.centerXAnchor, equalAnchorX: view.centerXAnchor, anchorY: stack.centerYAnchor, equalAnchorY: view.centerYAnchor)
        
let button = UIButton(title: "My Button", titleColor: .whiteColor(), target: self, selector: Selector("buttonClicked:"), buttonHeight: 100, buttonWidth: 200, backgroundColor: .purpleColor())
     
stack.addArrangedSubview(button)

Swift 4.1

let stack = UIStackView(axis: .vertical, spacing: 10)
        stack.anchorStackView(toView: view, anchorX: stack.centerXAnchor, equalAnchorX: view.centerXAnchor, anchorY: stack.centerYAnchor, equalAnchorY: view.centerYAnchor)
        
        let button = UIButton(title: "My Button", titleColor: .white, target: self, selector: #selector(buttonTapped(sender:)), buttonHeight: 100, buttonWidth: 200, backgroundColor: .purple)
        
        stack.addArrangedSubview(button)

... and they both lived happily ever after

But to complete this post we really need some code that utilises the stack view with multiple buttons, don't we? Something like this:

So here it is:
import UIKit

extension UIStackView {
    
    convenience init(axis:UILayoutConstraintAxis, spacing:CGFloat) {
        self.init()
        self.axis = axis
        self.spacing = spacing
        self.translatesAutoresizingMaskIntoConstraints = false
    }
    
    func anchorStackView(toView view:UIView, anchorX:NSLayoutXAxisAnchor, equalAnchorX:NSLayoutXAxisAnchor, anchorY:NSLayoutYAxisAnchor, equalAnchorY:NSLayoutYAxisAnchor) {
        view.addSubview(self)
        anchorX.constraintEqualToAnchor(equalAnchorX).active = true
        anchorY.constraintEqualToAnchor(equalAnchorY).active = true
        
    }
    
}

extension UIButton {
    convenience init(title:String, titleColor:UIColor, target:UIViewController, selector:Selector, buttonHeight:CGFloat, buttonWidth:CGFloat, backgroundColor:UIColor) {
        self.init()
        self.setTitle(title, forState: .Normal)
        self.setTitleColor(titleColor, forState: .Normal)
        self.addTarget(target, action: selector, forControlEvents: .TouchDown)
        self.backgroundColor = backgroundColor
        self.heightAnchor.constraintGreaterThanOrEqualToConstant(buttonHeight).active = true
        self.widthAnchor.constraintGreaterThanOrEqualToConstant(buttonWidth).active = true
    }
    
}


class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
    
        let stack = UIStackView(axis: .Vertical, spacing: 10)
        stack.anchorStackView(toView: view, anchorX: stack.centerXAnchor, equalAnchorX: view.centerXAnchor, anchorY: stack.centerYAnchor, equalAnchorY: view.centerYAnchor)
        
        let titleArray = ["First Button", "Second Button", "Third Button"]
        for title in titleArray {
            let button = UIButton(title: title, titleColor: .whiteColor(), target: self, selector: Selector("buttonClicked:"), buttonHeight: 80, buttonWidth: 200, backgroundColor: .purpleColor())
            
            stack.addArrangedSubview(button)
        }
    }


    func buttonClicked(sender:AnyObject?) {
        print(sender?.currentTitle)
    }


}
Enjoy cutting and pasting the code, and a Happy New Year!

Update: using multipliers to set the width and height of buttons

One alternative to setting a constant constraint on the height and width anchor is to use multipliers. The result of doing so in the following code means that the buttons become proportional to the height and width of the view to which they are anchored and that the size and width of the buttons changes with the screen orientation (and the size of the app window). Whereas in the code above the button height and width was set and this remained constant no matter which orientation the device was in.
import UIKit

extension UIStackView {
    
    convenience init(axis:UILayoutConstraintAxis, spacing:CGFloat) {
        self.init()
        self.axis = axis
        self.spacing = spacing
        self.translatesAutoresizingMaskIntoConstraints = false
    }
    
    func anchorStackView(toView view:UIView, anchorX:NSLayoutXAxisAnchor, equalAnchorX:NSLayoutXAxisAnchor, anchorY:NSLayoutYAxisAnchor, equalAnchorY:NSLayoutYAxisAnchor) {
        view.addSubview(self)
        anchorX.constraintEqualToAnchor(equalAnchorX).active = true
        anchorY.constraintEqualToAnchor(equalAnchorY).active = true
        
    }
    
}

extension UIButton {
    convenience init(title:String, titleColor:UIColor, target:UIViewController, selector:Selector, backgroundColor:UIColor) {
        self.init()
        self.setTitle(title, forState: .Normal)
        self.setTitleColor(titleColor, forState: .Normal)
        self.addTarget(target, action: selector, forControlEvents: .TouchDown)
        self.backgroundColor = backgroundColor
    }
    
}


class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        
        let stack = UIStackView(axis: .Vertical, spacing: 10)
        stack.anchorStackView(toView: view, anchorX: stack.centerXAnchor, equalAnchorX: view.centerXAnchor, anchorY: stack.centerYAnchor, equalAnchorY: view.centerYAnchor)
        
        let titleArray = ["First Button", "Second Button", "Third Button", "Fourth Button", "Fifth Button"]
        for title in titleArray {
            let button = UIButton(title: title, titleColor: .whiteColor(), target: self, selector: Selector("buttonClicked:"), backgroundColor: .purpleColor())
            
            stack.addArrangedSubview(button)
            
            // 80% of screen height should be used for buttons
            let screenHeightAvailableForButtons:CGFloat = 80/100
            // Calculate multiplier for height of the buttons
            let buttonMultiplier = screenHeightAvailableForButtons / CGFloat(titleArray.count)
            // 50% of screen width should be used for buttons
            let screenWidthMultiplier:CGFloat = 50/100
            
            button.widthAnchor.constraintEqualToAnchor(view.widthAnchor, multiplier: screenWidthMultiplier).active = true
            button.heightAnchor.constraintEqualToAnchor(view.heightAnchor, multiplier: buttonMultiplier).active = true

        }
    }
    
    
    func buttonClicked(sender:AnyObject?) {
        print(sender?.currentTitle)
    }
    
    
}

Swift 3, Xcode 8

import UIKit
import PlaygroundSupport // if you wish to use in a live playground

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        
        let stack = UIStackView(axis: .vertical, spacing: 10)
        stack.anchorStackView(toView: view, anchorX: stack.centerXAnchor, equalAnchorX: view.centerXAnchor, anchorY: stack.centerYAnchor, equalAnchorY: view.centerYAnchor)
        
        let titleArray = ["First Button", "Second Button", "Third Button", "Fourth Button", "Fifth Button"]
        for title in titleArray {
            let button = UIButton(title: title, titleColor: .white, target: self, selector: #selector(buttonClicked(sender:)), backgroundColor: .purple)
            
            stack.addArrangedSubview(button)
            
            // 80% of screen height should be used for buttons
            let screenHeightAvailableForButtons:CGFloat = 80/100
            // Calculate multiplier for height of the buttons
            let buttonMultiplier = screenHeightAvailableForButtons / CGFloat(titleArray.count)
            // 50% of screen width should be used for buttons
            let screenWidthMultiplier:CGFloat = 50/100
            
            button.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: screenWidthMultiplier).isActive = true
            button.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: buttonMultiplier).isActive = true
            
        }
    }
    
    
    func buttonClicked(sender:AnyObject?) {
        print(sender?.currentTitle)
    }
    
    
}
PlaygroundPage.current.liveView = ViewController() // if you wish to use in a live playground
Observe how it is necessary to add the buttons to the subview in this approach before constraints are set. This ordering is very important because the button is no longer sized independently.

Note: in real-world code there will need to be checks made with regard to text length and font size to ensure that the appearance of your buttons (no matter how many there are) look good, but hopefully this gets you started. (For further understanding of UIStackView behaviour, I wrote this response to a question about using a UITableView inside a UIStackView.)



Comments