Search code examples
iosswiftobjective-cautolayoutuilabel

autolayout-conform UILabel with vertical text (objC or Swift)?


How would I create an UIView / UILabel with vertical text flow which would look like the red view of this example screen?

view with vertical text

I have read about view.transform = CGAffineTransform(... which allows for easy rotation, BUT it would break the auto-layout constraints.

I would be happy to use a third-party library, but I cannot find any.


Solution

  • As noted in Apple's docs:

    In iOS 8.0 and later, the transform property does not affect Auto Layout. Auto layout calculates a view’s alignment rectangle based on its untransformed frame.

    So, to get transformed views to "play nice" with auto layout, we need to - in effect - tell constraints to use the opposite axis.

    For example, if we embed a UILabel in a UIView and rotate the label 90-degrees, we want to constrain the "container" view's Width to the label's Height and its Height to the label's Width.

    Here's a sample VerticalLabelView view subclass:

    class VerticalLabelView: UIView {
        
        public var numberOfLines: Int = 1 {
            didSet {
                label.numberOfLines = numberOfLines
            }
        }
        public var text: String = "" {
            didSet {
                label.text = text
            }
        }
        
        // vertical and horizontal "padding"
        //  defaults to 16-ps (8-pts on each side)
        public var vPad: CGFloat = 16.0 {
            didSet {
                h.constant = vPad
            }
        }
        public var hPad: CGFloat = 16.0 {
            didSet {
                w.constant = hPad
            }
        }
        
        // because the label is rotated, we need to swap the axis
        override func setContentHuggingPriority(_ priority: UILayoutPriority, for axis: NSLayoutConstraint.Axis) {
            label.setContentHuggingPriority(priority, for: axis == .horizontal ? .vertical : .horizontal)
        }
        
        // this is just for development
        //  show/hide border of label
        public var showBorder: Bool = false {
            didSet {
                label.layer.borderWidth = showBorder ? 1 : 0
                label.layer.borderColor = showBorder ? UIColor.red.cgColor : UIColor.clear.cgColor
            }
        }
        
        public let label = UILabel()
        
        private var w: NSLayoutConstraint!
        private var h: NSLayoutConstraint!
        private var mh: NSLayoutConstraint!
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
            addSubview(label)
            label.backgroundColor = .clear
            
            label.translatesAutoresizingMaskIntoConstraints = false
            
            // rotate 90-degrees
            let angle = .pi * 0.5
            label.transform = CGAffineTransform(rotationAngle: angle)
            
            // so we can change the "padding" dynamically
            w = self.widthAnchor.constraint(equalTo: label.heightAnchor, constant: hPad)
            h = self.heightAnchor.constraint(equalTo: label.widthAnchor, constant: vPad)
            
            NSLayoutConstraint.activate([
                
                label.centerXAnchor.constraint(equalTo: self.centerXAnchor),
                label.centerYAnchor.constraint(equalTo: self.centerYAnchor),
                w, h,
                
            ])
            
        }
        
    }
    

    I've added a few properties to allow the view to be treated like a label, so we can do:

    let v = VerticalLabelView()
    
    // "pass-through" properties
    v.text = "Some text which will be put into the label."
    v.numberOfLines = 0
    
    // directly setting properties
    v.label.textColor = .red
    

    This could, of course, be extended to "pass through" all label properties we need to use so we wouldn't need to reference the .label directly.

    This VerticalLabelView can now be used much like a normal UILabel.

    Here are two examples - they both use this BaseVC to setup the views:

    class BaseVC: UIViewController {
        
        let greenView: UIView = {
            let v = UIView()
            v.backgroundColor = .green
            return v
        }()
        let normalLabel: UILabel = {
            let v = UILabel()
            v.numberOfLines = 0
            return v
        }()
        
        let lYellow: VerticalLabelView = {
            let v = VerticalLabelView()
            v.backgroundColor = UIColor(red: 1.0, green: 1.0, blue: 0.5, alpha: 1.0)
            v.numberOfLines = 0
            return v
        }()
        
        let lRed: VerticalLabelView = {
            let v = VerticalLabelView()
            v.backgroundColor = UIColor(red: 1.0, green: 0.5, blue: 0.5, alpha: 1.0)
            v.numberOfLines = 0
            return v
        }()
        
        let lBlue: VerticalLabelView = {
            let v = VerticalLabelView()
            v.backgroundColor = UIColor(red: 0.3, green: 0.8, blue: 1.0, alpha: 1.0)
            v.numberOfLines = 1
            return v
        }()
        
        let container: UIView = {
            let v = UIView()
            v.backgroundColor = .systemYellow
            return v
        }()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let strs: [String] = [
                "Multiline Vertical Text",
                "Vertical Text",
                "Overflow Vertical Text",
            ]
            
            // default UILabel
            normalLabel.text = "Regular UILabel wrapping text"
            // add the normal label to the green view
            greenView.addSubview(normalLabel)
            
            // set text of vertical labels
            for (s, v) in zip(strs, [lYellow, lRed, lBlue]) {
                v.text = s
            }
            
            [container, greenView, normalLabel, lYellow, lRed, lBlue].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
            }
            
            // add greenView to the container
            container.addSubview(greenView)
            
            // add container to self's view
            view.addSubview(container)
            
            let g = view.safeAreaLayoutGuide
            
            NSLayoutConstraint.activate([
                
                // constrain container Top and CenterX
                container.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                container.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                
                // comment next line to allow container subviews to set the height
                container.heightAnchor.constraint(equalToConstant: 260.0),
                
                // comment next line to allow container subviews to set the width
                container.widthAnchor.constraint(equalToConstant: 160.0),
                
                // green view at Top, stretched full width
                greenView.topAnchor.constraint(equalTo: container.topAnchor, constant: 0.0),
                greenView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0.0),
                greenView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0.0),
    
                // constrain normal label in green view
                //  with 8-pts "padding" on all 4 sides
                normalLabel.topAnchor.constraint(equalTo: greenView.topAnchor, constant: 8.0),
                normalLabel.leadingAnchor.constraint(equalTo: greenView.leadingAnchor, constant: 8.0),
                normalLabel.trailingAnchor.constraint(equalTo: greenView.trailingAnchor, constant: -8.0),
                normalLabel.bottomAnchor.constraint(equalTo: greenView.bottomAnchor, constant: -8.0),
                
            ])
        }
        
    }
    

    The first example - SubviewsExampleVC - adds each as a subview, and then we add constraints between the views:

    class SubviewsExampleVC: BaseVC {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // add vertical labels to the container
            [lYellow, lRed, lBlue].forEach { v in
                container.addSubview(v)
            }
    
            NSLayoutConstraint.activate([
                
                // yellow label constrained to Bottom of green view
                lYellow.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: 0.0),
                // Leading to container Leading
                lYellow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0.0),
                
                // red label constrained to Bottom of green view
                lRed.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: 0.0),
                // Leading to yellow label Trailing
                lRed.leadingAnchor.constraint(equalTo: lYellow.trailingAnchor, constant: 0.0),
                
                // blue label constrained to Bottom of green view
                lBlue.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: 0.0),
                // Leading to red label Trailing
                lBlue.leadingAnchor.constraint(equalTo: lRed.trailingAnchor, constant: 0.0),
                
                // if we want the labels to fill the container width
                //  blue label Trailing constrained to container Trailing
                lBlue.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0.0),
                
                // using constraints to set the vertical label heights
                lYellow.heightAnchor.constraint(equalToConstant: 132.0),
                lRed.heightAnchor.constraint(equalTo: lYellow.heightAnchor),
                lBlue.heightAnchor.constraint(equalTo: lYellow.heightAnchor),
                
            ])
            
            // as always, we need to control which view(s)
            //  hug their content
    
            // so, for example, if we want the Yellow label to "stretch" horizontally
            lRed.setContentHuggingPriority(.required, for: .horizontal)
            lBlue.setContentHuggingPriority(.required, for: .horizontal)
            
            // or, for example, if we want the Red label to "stretch" horizontally
            //lYellow.setContentHuggingPriority(.required, for: .horizontal)
            //lBlue.setContentHuggingPriority(.required, for: .horizontal)
    
        }
    
    }
    

    The second example = StackviewExampleVC - adds each as an arranged subview of a UIStackView:

    class StackviewExampleVC: BaseVC {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // horizontal stack view
            let stackView = UIStackView()
            
            // add vertical labels to the stack view
            [lYellow, lRed, lBlue].forEach { v in
                stackView.addArrangedSubview(v)
            }
            
            stackView.translatesAutoresizingMaskIntoConstraints = false
            
            // add stack view to container
            container.addSubview(stackView)
            
            NSLayoutConstraint.activate([
                
                // constrain stack view Top to green view Bottom
                stackView.topAnchor.constraint(equalTo: greenView.bottomAnchor, constant: 0.0),
    
                // Leading / Trailing to container Leading / Trailing
                stackView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0.0),
                stackView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0.0),
                
                // stack view height
                stackView.heightAnchor.constraint(equalToConstant: 132.0),
                
            ])
    
            // as always, we need to control which view(s)
            //  hug their content
            // so, for example, if we want the Yellow label to "stretch" horizontally
            lRed.setContentHuggingPriority(.required, for: .horizontal)
            lBlue.setContentHuggingPriority(.required, for: .horizontal)
            
            // or, for example, if we want the Red label to "stretch" horizontally
            //lYellow.setContentHuggingPriority(.required, for: .horizontal)
            //lBlue.setContentHuggingPriority(.required, for: .horizontal)
            
        }
        
    }
    

    Both examples produce this output:

    enter image description here

    Please note: this is Example Code Only - it is not intended to be, nor should it be considered to be, Production Ready