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 extensionextension 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 playgroundObserve 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
Post a Comment