1

I have a UIStackView with a 2 arranged child UIViews. The second UIView (a title view) has contentHugging set to high, and when it's contents changes, the first UIView (a content view) stretches it's size correctly. Everything looks fine.

The problem is that the none of the first view's child views and descendants are notified when the UIStackView lays out it's views in response to the second view changing size.

I thought that viewDidLayoutSubviews was supposed to propagate downwards, so I added

override func viewDidLayoutSubviews() {
    let parentView = self.superview!

    // This is always .zero
    print(parentView.frame)

    super.viewDidLayoutSubviews()

    // This is ALSO always .zero
    print(parentView.frame)

}

to as many subviews as I could but it never seems to report any views changing more than once, so there goes that theory.

Since we can't observe UIView frame values, what is the correct way to make sure descendant views always know the frame size of the content view and can size themselves relative to it?

zakdances
  • 22,285
  • 32
  • 102
  • 173
  • i dont think so that contentHugging or contentCompression has any effect on arranged subviews – Jawad Ali May 15 '20 at 09:14
  • You'll need to provide more detail about your views and constraints. I just did a quick test with a "content view" holding two labels and a "title view" holding one label. Embed them in a stack view (with a fixed height). Changing the text of the label in the "title view" (so it wraps onto different numbers of lines, changing its height), causes the "content view" to resize and, as expected, resize the labels it is holding. – DonMag May 15 '20 at 15:08

1 Answers1

1

Here's a complete example showing that layoutSubviews() is called on all the arrangedSubviews of a stack view... and all of their subviews.

Here's how it looks to start:

enter image description here

  • the stack view has a "dashed outline"
  • the "bottom" (green) view is our "Title" view - one multiline label in a UIView
  • the "top" (yellow) view is our "Content" view - two labels and a "round" view

Each time we tap the button, the text in the Title view will change:

enter image description here

enter image description here

The fact the "round view" remains round tells us its layoutSubviews() (where we update the cornerRadius) is being called. If it wasn't, it would look like this:

enter image description here

Here's the code for this example (no @IBOutlet or @IBAction connections):

class StackExampleViewController: UIViewController {

    let testButton: UIButton = {
        let v = UIButton()
        v.setTitle("Tap Me", for: [])
        v.setTitleColor(.lightGray, for: .highlighted)
        v.backgroundColor = .blue
        return v
    }()

    let contentView: ContentView = {
        let v = ContentView()
        return v
    }()

    let titleView: TitleView = {
        let v = TitleView()
        return v
    }()

    let stackView: UIStackView = {
        let v = UIStackView()
        v.axis = .vertical
        v.alignment = .fill
        v.distribution = .fill
        v.spacing = 8
        return v
    }()

    let dashedView: DashedBorderView = {
        let v = DashedBorderView()
        return v
    }()

    let sampleData: [String] = [
        "This is the Title View",
        "A label can contain an arbitrary amount of text, but UILabel may shrink, wrap, or truncate the text, depending on the size of the bounding rectangle and properties you set.",
        "You can control the font, text color, alignment, highlighting, and shadowing of the text in the label.",
        "What's a UIButton?",
        "You can set the title, image, and other appearance properties of a button. In addition, you can specify a different appearance for each button state."
    ]

    var idx: Int = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        // standard auto-layout
        [testButton, contentView, titleView, stackView, dashedView].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
        }

        // add contentView and titleView to stackView
        stackView.addArrangedSubview(contentView)
        stackView.addArrangedSubview(titleView)

        // add button, dashedView and stackView
        //  (dashedView will be used to show the frame of stackView)
        view.addSubview(testButton)
        view.addSubview(dashedView)
        view.addSubview(stackView)

        // respect safe-area
        let g = view.safeAreaLayoutGuide

        NSLayoutConstraint.activate([

            // testButton 40-pts from top, centeredX
            testButton.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
            testButton.centerXAnchor.constraint(equalTo: g.centerXAnchor),

            // stackView 40-pts from testButton
            //  40-pts on each side
            stackView.topAnchor.constraint(equalTo: testButton.bottomAnchor, constant: 40.0),
            stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
            stackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),

            // stackView height = 300
            stackView.heightAnchor.constraint(equalToConstant: 300.0),

            // constrain dashedView centered to stackView
            //  width and height 2-pts greater (so we can "outline" the stackView frame)
            dashedView.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: 2),
            dashedView.heightAnchor.constraint(equalTo: stackView.heightAnchor, constant: 2),
            dashedView.centerXAnchor.constraint(equalTo: stackView.centerXAnchor),
            dashedView.centerYAnchor.constraint(equalTo: stackView.centerYAnchor),

        ])

        // add touchUp target
        testButton.addTarget(self, action: #selector(self.didTap(_:)), for: .touchUpInside)

        // this will track the text for updating titleView's label
        idx = sampleData.count
        updateText()
    }

    func updateText() -> Void {
        // change the text in titleView's titleLabel
        //  let auto-layout handle ALL of the resizing
        titleView.titleLabel.text = sampleData[idx % sampleData.count]
        idx += 1
    }

    @objc func didTap(_ sender: Any) {
        updateText()
    }

}

class TitleView: UIView {

    // TitleView has a multi-line UILabel
    //  with 20-pts "padding" on each side
    //  and 12-pts "padding" on top and bottom

    var titleLabel: UILabel = {
        let v = UILabel()
        v.text = "Title Label"
        v.numberOfLines = 0
        return v
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    func commonInit() -> Void {

        backgroundColor = .green

        [titleLabel].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            $0.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
            $0.textAlignment = .center
            addSubview($0)
        }

        NSLayoutConstraint.activate([

            titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 12.0),
            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20.0),
            titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20.0),
            titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12.0),

        ])

        // we want this label's text to control its height
        titleLabel.setContentHuggingPriority(.required, for: .vertical)

    }

    override func layoutSubviews() {
        super.layoutSubviews()
        print(NSStringFromClass(type(of: self)), #function, bounds)
    }

}

class ContentView: UIView {

    // ContentView has two labels and a "round" view

    var labelA: UILabel = {
        let v = UILabel()
        v.text = "Label A"
        return v
    }()

    var labelB: UILabel = {
        let v = UILabel()
        v.text = "The Content View is Yellow"
        return v
    }()

    var roundView: RoundView = {
        let v = RoundView()
        v.backgroundColor = .orange
        return v
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    func commonInit() -> Void {

        backgroundColor = .yellow

        [labelA, labelB].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            $0.backgroundColor = .cyan
            $0.textAlignment = .center
            addSubview($0)
        }

        roundView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(roundView)

        NSLayoutConstraint.activate([

            // constrain labelA 20-pts from top / leading
            labelA.topAnchor.constraint(equalTo: topAnchor, constant: 20.0),
            labelA.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20.0),

            // constrain roundView 20-pts from trailing
            roundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20.0),

            // constrain labelA trailing 20-pts from roundView leading
            labelA.trailingAnchor.constraint(equalTo: roundView.leadingAnchor, constant: -20.0),

            // constrain roundView height equal to lableA height
            roundView.heightAnchor.constraint(equalTo: labelA.heightAnchor),
            // keep roundView square (1:1 ratio)
            roundView.widthAnchor.constraint(equalTo: roundView.heightAnchor),

            // center roundView vertically to labelA
            roundView.centerYAnchor.constraint(equalTo: labelA.centerYAnchor),

            // labelB top is 8-pts below labelA
            labelB.topAnchor.constraint(equalTo: labelA.bottomAnchor, constant: 8.0),

            // constrain labelB 20-pts from leading / trailing / bottom
            labelB.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20.0),
            labelB.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20.0),
            labelB.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20.0),

            // keep labelA and labelB heights equal
            labelA.heightAnchor.constraint(equalTo: labelB.heightAnchor),

        ])

    }

    override func layoutSubviews() {
        super.layoutSubviews()
        print(NSStringFromClass(type(of: self)), #function, bounds)
    }

}

class RoundView: UIView {

    override func layoutSubviews() {
        super.layoutSubviews()
        print(NSStringFromClass(type(of: self)), #function, bounds)
        // update cornerRadius here to keep it round
        layer.cornerRadius = bounds.size.width * 0.5
    }

}

class DashedBorderView: UIView {

    // simple view with dashed border

    var shapeLayer: CAShapeLayer!

    override class var layerClass: AnyClass {
        return CAShapeLayer.self
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    func commonInit() -> Void {

        shapeLayer = self.layer as? CAShapeLayer
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.strokeColor = UIColor(red: 1.0, green: 0.25, blue: 0.25, alpha: 1.0).cgColor
        shapeLayer.lineWidth = 1.0
        shapeLayer.lineDashPattern = [8,8]

    }

    override func layoutSubviews() {
        super.layoutSubviews()
        print(NSStringFromClass(type(of: self)), #function, bounds)
        shapeLayer.path = UIBezierPath(rect: bounds).cgPath
    }
}

Note: the views also print() their class name and bounds when they receive calls to layoutSubviews(), so you'll see something like this in debug console:

SampleApp.TitleView layoutSubviews() (0.0, 0.0, 295.0, 146.0)
SampleApp.ContentView layoutSubviews() (0.0, 0.0, 295.0, 146.0)
SampleApp.RoundView layoutSubviews() (0.0, 0.0, 49.0, 49.0)
SampleApp.TitleView layoutSubviews() (0.0, 0.0, 295.0, 105.5)
SampleApp.ContentView layoutSubviews() (0.0, 0.0, 295.0, 186.5)
SampleApp.RoundView layoutSubviews() (0.0, 0.0, 69.0, 69.5)
DonMag
  • 69,424
  • 5
  • 50
  • 86