Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UIStackView, 2 labels: Change Axis based on size

I have two labels embedded inside a horizontal UIStackView. One of the labels might grow too large in size, therefore, one of them is truncated.

Or, their sizes are being split in some proportion and they grow vertically.

What I'm trying to achieve is to make UIStackView change axis to vertical if the labels do not fit.

Please, refer to the two figures attached below:

Figure 1: Labels fit in one line, a horizontal axis is used.
Figure 2: One of the labels is multiline. A vertical axis is used.

The behavior is similar when using UICollectionViewFlowLayout.

Example StackView behavior

like image 657
Richard Topchii Avatar asked Dec 14 '22 16:12

Richard Topchii


2 Answers

Give this a go. You just have to measure the text and you have to have a stack view wrapped with another stackview.

import UIKit

class ViewController: UIViewController {

    var stackView : UIStackView?
    var outerStackView : UIStackView?
    var label1 = UILabel()
    var label2 = UILabel()

    var heightAnchor : NSLayoutConstraint?
    var widthAnchor : NSLayoutConstraint?
    var button : UIButton?

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        createStackView()
        createLabelsAndAddToStack()

        button = UIButton(frame: CGRect(x: 20, y: self.view.frame.height - 60, width: self.view.frame.width - 40, height: 40))
        button?.backgroundColor = .green
        button?.setTitleColor(.black, for: .normal)
        button?.setTitle("Toggle Text", for: .normal)
        button?.addTarget(self, action: #selector(toggleText), for: .touchUpInside)
        button?.setTitleColor(.gray, for: .highlighted)
        self.view.addSubview(button!)
    }

    func createStackView(){

        outerStackView = UIStackView(frame: CGRect(x: 20, y: 20, width: self.view.bounds.width - 40, height: 100))
        outerStackView?.axis = .vertical
        outerStackView?.distribution = .fill
        outerStackView?.alignment = .center
        outerStackView?.translatesAutoresizingMaskIntoConstraints = false

        self.view.addSubview(outerStackView!)

        outerStackView?.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 40).isActive = true
        outerStackView?.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -40).isActive = true
        outerStackView?.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 50).isActive = true


        stackView = UIStackView(frame: CGRect(x: 20, y: 20, width: self.view.bounds.width - 40, height: 100))
        stackView?.alignment = .center
        stackView?.spacing = 20
        stackView?.distribution = .fillEqually
        stackView?.axis = .horizontal
        outerStackView?.addArrangedSubview(stackView!)

        heightAnchor = stackView?.heightAnchor.constraint(equalTo: outerStackView!.heightAnchor)
        widthAnchor = stackView?.widthAnchor.constraint(equalTo: outerStackView!.widthAnchor)

    }

    func createLabelsAndAddToStack(){
        label1 = UILabel(frame: .zero)
        label1.textColor = .black
        label1.font = UIFont.systemFont(ofSize: 17)
        label1.text = "Label 1"
        label1.numberOfLines = 0
        stackView?.addArrangedSubview(label1)

        label2 = UILabel(frame: .zero)
        label2.font = UIFont.systemFont(ofSize: 17)
        label2.textColor = .green
        label2.text = "Label 2"
        label2.numberOfLines = 0
        stackView?.addArrangedSubview(label2)

    }

    func adaptStackView(){

        self.outerStackView?.layoutIfNeeded()
        self.stackView?.layoutIfNeeded()
        //let maxWidth = label2.frame.width
        //if you want it to be the max it could be
        let maxWidth = (self.outerStackView!.frame.width - stackView!.spacing)/2
        let height = label2.font.lineHeight

        if let label1Text = label1.text{
            //lets measure
            if label1Text.width(withConstraintedHeight: height, font: label1.font) > maxWidth{
                outerStackView?.axis = .horizontal
                outerStackView?.distribution = .fill

                stackView?.axis = .vertical
                stackView?.distribution = .fill
                stackView?.alignment = .leading

                widthAnchor?.isActive = true
                heightAnchor?.isActive = true

            }else{
                outerStackView?.axis = .vertical
                outerStackView?.distribution = .fill

                stackView?.alignment = .center
                stackView?.axis = .horizontal
                stackView?.distribution = .fillEqually

                stackView?.removeConstraints(self.stackView!.constraints)
                widthAnchor?.isActive = false
                widthAnchor?.isActive = false
            }

            UIView.animate(withDuration: 0.5) {
                self.view.layoutIfNeeded()
            }
        }
    }

    @objc func toggleText(){
        if label1.text == "Label 1"{
            label1.text = "This is some longer text to test everything out to see how we are doing"

        }else{
            label1.text = "Label 1"
        }
        adaptStackView()
    }

}

extension String{
    func width(withConstraintedHeight height: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height)
        let boundingBox = self.boundingRect(with: constraintRect, options: [.usesLineFragmentOrigin], attributes: [.font: font], context: nil)

        return ceil(boundingBox.width)
    }
}

stackgif

like image 176
agibson007 Avatar answered Dec 16 '22 05:12

agibson007


if you are searching for a more generic solution here is what works for me:

//
//  UIStackView+.swift
//

import UIKit

extension UIStackView {

    // Check if a given set of views and subviews fit into their desired frame
    private func checkSubviews(views: [UIView], includeSubviews: Bool = false) -> Bool {

        for view in views {
            let idealSize = view.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: view.bounds.height))
            if idealSize.width > view.bounds.width {
                axis = .vertical
                return false
            }
            if includeSubviews {
                if checkSubviews(views: view.subviews, includeSubviews: true) == false {
                    return false
                }
            }
        }
        return true
    }

    @discardableResult
    func updateAxisToBestFit(views: [UIView], includeSubviews: Bool = false) -> UILayoutConstraintAxis {
        self.axis = .horizontal
        setNeedsLayout()
        layoutIfNeeded()
        let subviewsFit = checkSubviews(views: views, includeSubviews: includeSubviews)
        guard subviewsFit == false else { return .horizontal }
        self.axis = .vertical
        setNeedsLayout()
        layoutIfNeeded()
        return .vertical
    }

    @discardableResult
    func updateAxisToBestFit() -> UILayoutConstraintAxis {
        return updateAxisToBestFit(views: self.arrangedSubviews, includeSubviews: true)
    }

}

The usage would be then:

class MyCellView: UITableCellView {
    @IBOutlet weak var myStackView: UIStackView!

    public func configure(with model: MyCellViewModel) {
        // update cell content according to model
        myStackView?.updateAxisToBestFit()
    }
}

like image 24
hhamm Avatar answered Dec 16 '22 06:12

hhamm