Search code examples
swiftuiviewuibuttonnslayoutconstraintuistackview

Button Constraints within a StackView (Swift Programmatically)


I'm new to setting up StackViews and Buttons programmatically. I am getting some strange behavior with my constraints I cannot figure out what I'm doing wrong. It feels like I'm missing something simple. Any help is greatly appreciated!

I am trying to add two buttons to a StackView to create a custom tab bar. However, when I add the constraints to the buttons they are showing up outside the bottom of StackView. It's like the top constraint of Earth image isn't working. Any ideas? See image and code below.

enter image description here

// View to put in the StackView
class ProfileBottomTabBarView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.translatesAutoresizingMaskIntoConstraints = false
        self.backgroundColor = .blue
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}

// Calculate the screen height
public var screenHeight: CGFloat {
    return UIScreen.main.bounds.height
}

// StackView height set to a proporation of screen height
let stackViewHeight = screenHeight * 0.07

// Views to put in the StackView
let profileIconView = ProfileBottomTabBarView()
let actIconView = ActBottomTabBarView()
let achieveIconView = AchieveBottomTabBarView()
let growIconView = GrowBottomTabBarView()

// Buttons to put in the Views
let profileButton = UIButton(type: .system)
let actButton = UIButton(type: .system)
let achieveButton = UIButton(type: .system)
let growButton = UIButton(type: .system)

let profileButtonText = UIButton(type: .system)
let actButtonText = UIButton(type: .system)
let achieveButtonText = UIButton(type: .system)
let growButtonText = UIButton(type: .system)

// Stackview setup
lazy var stackView: UIStackView = {

    let stackV = UIStackView(arrangedSubviews: [profileIconView, actIconView, achieveIconView, growIconView])

    stackV.translatesAutoresizingMaskIntoConstraints = false
    stackV.axis = .horizontal
    stackV.spacing = 20
    stackV.distribution = .fillEqually

    return stackV
}()


override func viewDidLoad() {
    super.viewDidLoad()

view.backgroundColor = .black

    // Add StackView
    view.addSubview(stackView)

    stackView.bottomAnchor.constraint(equalTo: view.safeBottomAnchor).isActive = true
    stackView.leadingAnchor.constraint(equalTo: view.safeLeadingAnchor).isActive = true
    stackView.trailingAnchor.constraint(equalTo: view.safeTrailingAnchor).isActive = true

    // Set height of the bottom tab bar as a proportion of the screen height.
    stackView.heightAnchor.constraint(equalToConstant: stackViewHeight).isActive = true

    profileIconView.topAnchor.constraint(equalTo: stackView.topAnchor).isActive = true
    profileIconView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor).isActive = true
    profileIconView.heightAnchor.constraint(equalToConstant: stackViewHeight).isActive = true


    // Add Buttons to the View
    profileIconView.addSubview(profileButton)
    profileIconView.addSubview(profileButtonText)

    profileButton.translatesAutoresizingMaskIntoConstraints = false
    profileButtonText.translatesAutoresizingMaskIntoConstraints = false


    // Profile Button with Earth Image Setup
    profileButton.setImage(UIImage(named: "earthIcon"), for: .normal)
    profileButton.imageView?.contentMode = .scaleAspectFit

    profileButton.topAnchor.constraint(equalTo: profileIconView.topAnchor).isActive = true
    profileButton.bottomAnchor.constraint(equalTo: profileButtonText.topAnchor).isActive = true
    profileButton.centerXAnchor.constraint(equalTo: profileIconView.centerXAnchor).isActive = true

    //Set height of icon to a proportion of the stackview height
    let profileButtonHeight = stackViewHeight * 0.8
    profileButton.heightAnchor.constraint(equalTo: profileIconView.heightAnchor, constant: profileButtonHeight).isActive = true

    profileButton.widthAnchor.constraint(equalToConstant: profileButtonHeight).isActive = true
    profileButton.imageView?.widthAnchor.constraint(equalToConstant: profileButtonHeight)
    profileButton.imageView?.heightAnchor.constraint(equalToConstant: profileButtonHeight)


    // Profile Text Button Setup

    profileButtonText.setTitle("Profile", for: .normal)
    profileButtonText.titleLabel?.font = UIFont.boldSystemFont(ofSize: 12)
    profileButtonText.setTitleColor(.white, for: .normal)

    profileButtonText.topAnchor.constraint(equalTo: profileButton.bottomAnchor).isActive = true
    profileButtonText.bottomAnchor.constraint(equalTo: profileIconView.bottomAnchor).isActive = true
    profileButtonText.centerXAnchor.constraint(equalTo: profileIconView.centerXAnchor).isActive = true

    //Set height of icon to a proportion of the stackview height
    let profileButtonTextHeight = stackViewHeight * 0.2
    profileButton.heightAnchor.constraint(equalTo: profileIconView.heightAnchor, constant: profileButtonTextHeight).isActive = true
    profileButtonText.widthAnchor.constraint(equalToConstant: 40).isActive = true

}

Solution

  • A few things wrong with your constraints...

    You're calculating heights / widths and using them as constants, but those values may (almost certainly will) change based on view lifecycle.

    Better to use only related constraints. For example:

            // constrain profile image button top, centerX and width relative to the iconView
            profileButton.topAnchor.constraint(equalTo: profileIconView.topAnchor),
            profileButton.centerXAnchor.constraint(equalTo: profileIconView.centerXAnchor),
            profileButton.widthAnchor.constraint(equalTo: profileIconView.widthAnchor, multiplier: 1.0),
    
            // constrain profile text button bottom, centerX and width relative to the iconView
            profileButtonText.centerXAnchor.constraint(equalTo: profileIconView.centerXAnchor),
            profileButtonText.widthAnchor.constraint(equalTo: profileIconView.widthAnchor, multiplier: 1.0),
            profileButtonText.bottomAnchor.constraint(equalTo: profileIconView.bottomAnchor),
    
            // constrain bottom of image button to top of text button (with a padding of 4-pts, change to suit)
            profileButton.bottomAnchor.constraint(equalTo: profileButtonText.topAnchor, constant: -4.0),
    
            // constrain height of text button to 20% of height of iconView
            profileButtonText.heightAnchor.constraint(equalTo: profileIconView.heightAnchor, multiplier: 0.2),
    

    To make things easier on yourself, I'd suggest creating a BottomTabBarView that handles adding and constraining your buttons:

    class BottomTabBarView: UIView {
    
        var theImageButton: UIButton = {
            let v = UIButton()
            v.translatesAutoresizingMaskIntoConstraints = false
            v.imageView?.contentMode = .scaleAspectFit
            return v
        }()
    
        var theTextButton: UIButton = {
            let v = UIButton()
            v.translatesAutoresizingMaskIntoConstraints = false
            v.titleLabel?.font = UIFont.boldSystemFont(ofSize: 12)
            v.setTitleColor(.white, for: .normal)
            return v
        }()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
    
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
    
        convenience init(withImageName imageName: String, labelText: String, bkgColor: UIColor) {
    
            self.init()
            self.commonInit()
            theImageButton.setImage(UIImage(named: imageName), for: .normal)
            theTextButton.setTitle(labelText, for: .normal)
            backgroundColor = bkgColor
    
        }
    
        func commonInit() -> Void {
            self.translatesAutoresizingMaskIntoConstraints = false
    
            addSubview(theImageButton)
            addSubview(theTextButton)
    
            NSLayoutConstraint.activate([
    
                // constrain profile image button top, centerX and width of the iconView
                theImageButton.topAnchor.constraint(equalTo: topAnchor),
                theImageButton.centerXAnchor.constraint(equalTo: centerXAnchor),
                theImageButton.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1.0),
    
                // constrain profile text button bottom, centerX and width of the iconView
                theTextButton.centerXAnchor.constraint(equalTo: centerXAnchor),
                theTextButton.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1.0),
                theTextButton.bottomAnchor.constraint(equalTo: bottomAnchor),
    
                // constrain bottom of image button to top of text button
                theImageButton.bottomAnchor.constraint(equalTo: theTextButton.topAnchor, constant: -4.0),
    
                // set text button height to 20% of view height (instead of using intrinsic height)
                theTextButton.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.2),
    
                ])
    
        }
    
    }
    

    Now you can create each view with a single line, as in:

        profileIconView = BottomTabBarView(withImageName: "earthIcon", labelText: "Profile", bkgColor: .blue)
    

    And your view controller class becomes much simpler / cleaner:

    class BenViewController: UIViewController {
    
        // Views to put in the StackView
        var profileIconView = BottomTabBarView()
        var actIconView = BottomTabBarView()
        var achieveIconView = BottomTabBarView()
        var growIconView = BottomTabBarView()
    
        // Stackview setup
        lazy var stackView: UIStackView = {
            let stackV = UIStackView(arrangedSubviews: [profileIconView, actIconView, achieveIconView, growIconView])
    
            stackV.translatesAutoresizingMaskIntoConstraints = false
            stackV.axis = .horizontal
            stackV.spacing = 20
            stackV.distribution = .fillEqually
    
            return stackV
        }()
    
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = .black
    
            profileIconView = BottomTabBarView(withImageName: "earthIcon", labelText: "Profile", bkgColor: .blue)
            actIconView = BottomTabBarView(withImageName: "actIcon", labelText: "Action", bkgColor: .brown)
            achieveIconView = BottomTabBarView(withImageName: "achieveIcon", labelText: "Achieve", bkgColor: .red)
            growIconView = BottomTabBarView(withImageName: "growIcon", labelText: "Grow", bkgColor: .purple)
    
            // Add StackView
            view.addSubview(stackView)
    
            NSLayoutConstraint.activate([
    
                // constrain stackView to bottom, leading and trailing (to safeArea)
                stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
                stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
                stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
    
                // Set height of the stackView (the bottom tab bar) as a proportion of the view height (7%).
                stackView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.07),
    
                ])
    
        }
    }