Search code examples
swiftuitableviewuibuttonheightuistackview

UIStack of UIButton dynamic size / height according to title


I have UITableview with custom cells. In each cell I have on one side a vertical UIStack view of UIButtons. The number of the buttons per stack varies per cell.

As part of the code for the cell I create an array of UIButtons, each with a different title, then add these to a the UIStack via addArrangedSubview(). The UIButtons are created solely in code, while the UIStack is created on storyboard with some overrides in code.

All works well.

However, if the title is larger than 1 line on the button the UIButton doesn't resize to accommodate. On the image, note the overlapping text on the 2nd button next to Consciousness.

enter image description here

Code (all within the UITableview cellatrow function:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        guard let cell = tableView.dequeueReusableCell(withIdentifier: FormVerticalCell.reuseIdentifier, for: indexPath) as? FormVerticalCell else {
            fatalError("Unexpected Index Path")
        }

        for arrangedSubview in cell.buttonStack.arrangedSubviews {
            cell.buttonStack.removeArrangedSubview(arrangedSubview)
        }
        
        let tempOptions = formArray[indexPath.row][1]
        let optionsArray = tempOptions.components(separatedBy: ",")
        
        var btnArray = [UIButton]()
        for i in 0..<optionsArray.count {

            let button = UIButton()
            button.changesSelectionAsPrimaryAction = true
        
            button.setTitle(optionsArray[i], for: .normal)
            button.titleLabel?.numberOfLines = 0
            button.titleLabel?.lineBreakMode = .byWordWrapping
            button.titleLabel?.textAlignment = .left
            button.setTitleColor(.black, for: .normal)
            button.setTitleColor(.white, for: .selected)
            button.setTitleColor(.white, for: .highlighted)
            
            button.tag = i
            button.setBackgroundColor(UIColor(red:0.9, green:0.9, blue:0.9, alpha:1.0), forState: .normal)
            button.setBackgroundColor(UIColor(red:0.8, green:0.6, blue:0.0, alpha:1.0), forState: .highlighted)
            button.setBackgroundColor(UIColor(red:0.8, green:0.6, blue:0.0, alpha:1.0), forState: .selected)

            btnArray.append(button)
           
            let tempOptionsScores = formArray[indexPath.row][2]
 
            
            button.addAction { [self] in
                    
                    let optionsScoresArray = tempOptionsScores.components(separatedBy: ",")
                    let tempScore = optionsScoresArray[button.tag]
                    scoreArray[indexPath.row] = Int(tempScore)!
                    updateScore()
                    
                    for i in 0..<btnArray.count
                        {
                            if (i != button.tag) {
                            btnArray[i].isSelected = false
                        }
                        
                    }
                }
            }

        for i in 0..<btnArray.count {
            cell.buttonStack.addArrangedSubview(btnArray[i])
            }
   
        cell.buttonStack.axis = .vertical
        cell.buttonStack.alignment = .fill
        cell.buttonStack.distribution = .fillProportionally
        cell.buttonStack.spacing = 2.0
        cell.buttonStack.translatesAutoresizingMaskIntoConstraints = false


        cell.label.text = formArray[indexPath.row][0]
        
        // WHY NOT BLACK!!!
        cell.label.textColor = .black

        return cell
    }

Already looked at a few questions - no current up to date answers I could find. Any advice on steps I need to take to:

  1. Dynamically increase height of UIButton to accommodate the title fully.
  2. Ideally add a little padding around the title so the text is not right against the edge.

UPDATE

Seemed to be working well. But randomly if a button has more than 2 lines of text, then the text spills out over the edges. However if the tableview is long enough to scroll - scrolling up and down somehow fixes this (see 2 images below). I presume some issue with the table cell height creation (automatic) - but unsure what, and why only occurs if > 2 lines of textpost scrolling

spilling text


Solution

  • If you don't want to use the newer style buttons that automatically handle multi-line titles, you can use a subclassed button like this:

    class MultilineTitleButton: UIButton {
        
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        
        func commonInit() -> Void {
            self.titleLabel?.numberOfLines = 0
            self.titleLabel?.textAlignment = .center
        }
        
        override var intrinsicContentSize: CGSize {
            let size = self.titleLabel!.intrinsicContentSize
            return CGSize(width: size.width + contentEdgeInsets.left + contentEdgeInsets.right, height: size.height + contentEdgeInsets.top + contentEdgeInsets.bottom)
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            titleLabel?.preferredMaxLayoutWidth = self.titleLabel!.frame.size.width
        }
        
    }
    

    A sample view controller that adds 4 buttons in a vertical stack view, with 200-points width:

    class MultilineButtonVC: UIViewController {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let stackView = UIStackView()
            stackView.axis = .vertical
            stackView.spacing = 8
            // do NOT use .fillEqually
            stackView.distribution = .fill
            
            stackView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stackView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                stackView.widthAnchor.constraint(equalToConstant: 200.0),
            ])
            
            let titles: [String] = [
                "Short.",
                "A little longer.",
                "This title is long enough to wrap onto multiple lines.",
                "A fourth button in the stackView.",
            ]
            
            titles.forEach { str in
                let b = MultilineTitleButton()
                b.setTitle(str, for: [])
                b.setTitleColor(.black, for: .normal)
                b.setTitleColor(.lightGray, for: .highlighted)
                b.backgroundColor = .yellow
                b.contentEdgeInsets = .init(top: 6.0, left: 8.0, bottom: 6.0, right: 8.0)
                stackView.addArrangedSubview(b)
            }
            
        }
        
    }
    

    Looks like this:

    enter image description here


    Edit - in response to comments...

    Same thing as above, but using the iOS 15+ UIButton.Configuration style buttons - so, no need for the custom MultilineTitleButton class:

    class MultilineConfigButtonVC: UIViewController {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let stackView = UIStackView()
            stackView.axis = .vertical
            stackView.spacing = 8
            // do NOT use .fillEqually
            stackView.distribution = .fill
            
            stackView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stackView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                stackView.widthAnchor.constraint(equalToConstant: 200.0),
            ])
            
            let titles: [String] = [
                "Short.",
                "A little longer.",
                "This title is long enough to wrap onto multiple lines.",
                "A fourth button in the stackView.",
            ]
            
            // let's make the button titles
            //  18-point system font
            //  centered horizontally
            let centerStyle = NSMutableParagraphStyle()
            centerStyle.alignment = NSTextAlignment.center
            var ac = AttributeContainer()
            ac.font = .systemFont(ofSize: 18.0)
            ac.paragraphStyle = centerStyle
    
            titles.forEach { str in
                var cfg = UIButton.Configuration.filled()
                cfg.attributedTitle = AttributedString(str, attributes: ac)
                cfg.baseBackgroundColor = UIColor(white: 0.9, alpha: 1.0)
                cfg.baseForegroundColor = .black
                let b = UIButton(configuration: cfg)
                stackView.addArrangedSubview(b)
            }
            
        }
        
    }
    

    Looks about the same... (using a very light gray instead of yellow for the background):

    enter image description here