Search code examples
swiftuikitnslayoutconstraintios-autolayoutuitraitcollection

When trait collection changes, constraint conflicts arise as though the stackview axis didn't change


I've a stackview with two controls.

When the UI is not vertically constrained: Vertical1

When the UI is vertically constrained: Horizontal1

I get both UIs as pictured. There are no constraint conflicts when I show the UIs the first time. However, when I go from vertically constrained to vertical = regular, I get constraint conflicts.

When I comment out the stackview space (see code comment below), I don't get a constraint conflict.

class ViewController: UIViewController {

    var rootStack: UIStackView!
    var aggregateStack: UIStackView!
    var field1: UITextField!
    var field2: UITextField!
    var f1f2TrailTrail: NSLayoutConstraint!

    override func viewDidLoad() {

        super.viewDidLoad()
        view.backgroundColor = .white
        createIntializeViews()
        createInitializeAddStacks()
    }

    private func createIntializeViews() {

        field1 = UITextField()
        field2 = UITextField()
        field1.text = "test 1"
        field2.text = "test 2"
    }

    private func createInitializeAddStacks() {

        rootStack = UIStackView()             

        aggregateStack = UIStackView()

        // If I comment out the following, there are no constraint conflicts
        aggregateStack.spacing = 2            

        aggregateStack.addArrangedSubview(field1)
        aggregateStack.addArrangedSubview(field2)
        rootStack.addArrangedSubview(aggregateStack)

        view.addSubview(rootStack)

        rootStack.translatesAutoresizingMaskIntoConstraints = false
        aggregateStack.translatesAutoresizingMaskIntoConstraints = false
        field1.translatesAutoresizingMaskIntoConstraints = false
        field2.translatesAutoresizingMaskIntoConstraints = false

        f1f2TrailTrail = field2.trailingAnchor.constraint(equalTo: field1.trailingAnchor)
    }


    override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {

        super.traitCollectionDidChange(previousTraitCollection)

        if traitCollection.verticalSizeClass == .regular {
            aggregateStack.axis = .vertical
            f1f2TrailTrail.isActive = true
        } else if traitCollection.verticalSizeClass == .compact {
            f1f2TrailTrail.isActive = false
            aggregateStack.axis = .horizontal
        } else {
            print("Unexpected")
        }
    }
}

The constraint conflicts are here -

(
    "<NSLayoutConstraint:0x600001e7d1d0 UITextField:0x7f80b2035000.trailing == UITextField:0x7f80b201d000.trailing   (active)>",
    "<NSLayoutConstraint:0x600001e42800 'UISV-spacing' H:[UITextField:0x7f80b201d000]-(2)-[UITextField:0x7f80b2035000]   (active)>"
)

Will attempt to recover by breaking constraint 
    <NSLayoutConstraint:0x600001e42800 'UISV-spacing' H:[UITextField:0x7f80b201d000]-(2)-[UITextField:0x7f80b2035000]   (active)>

When I place the output in www.wtfautolayout.com, I get the following: Easier to Read Output

The second constraint shown in the above image makes me think the change to stackview vertical axis did not happen before constraints were evaluated.

Can anyone tell me what I've done wrong or how to properly set this up (without storyboard preferably)?

[EDIT] The textfields are trailing edge aligned to have this:

More of the form - portrait

More of the form - landscape


Solution

  • Couple notes...

    • There is an inherent issue with "nested" stack views causing constraint conflicts. This can be avoided by setting the priority on affected elements to 999 (instead of the default 1000).
    • Your layout becomes a bit complex... Labels "attached" to text fields; elements needing to be on two "lines" in portrait orientation or one "line" in landscape; one element of a "multi-element line" having a different height (the stepper); and so on.
    • To get your "field2" and "field3" to be equal size, you need to constrain their widths to be equal, even though they are not subviews of the same subview. This is perfectly valid, as long as they are descendants of the same view hierarchy.
    • Stackviews are great --- except when they're not. I would almost suggest using constraints only. You need to add more constraints, but it might avoid some issues with stack views.

    However, here is an example that should get you on your way.

    I've added a UIStackView subclass named LabeledFieldStackView ... it sets up the Label-above-Field in a stack view. Somewhat cleaner than mixing it in within all the other layout code.

    class LabeledFieldStackView: UIStackView {
    
        var theLabel: UILabel = {
            let v = UILabel()
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
    
        var theField: UITextField = {
            let v = UITextField()
            v.translatesAutoresizingMaskIntoConstraints = false
            v.borderStyle = .roundedRect
            return v
        }()
    
        convenience init(with labelText: String, fieldText: String, verticalGap: CGFloat) {
    
            self.init()
    
            axis = .vertical
            alignment = .fill
            distribution = .fill
            spacing = 2
    
            addArrangedSubview(theLabel)
            addArrangedSubview(theField)
    
            theLabel.text = labelText
            theField.text = fieldText
    
            self.translatesAutoresizingMaskIntoConstraints = false
    
        }
    
    }
    
    class LargentViewController: UIViewController {
    
        var rootStack: UIStackView!
    
        var fieldStackView1: LabeledFieldStackView!
        var fieldStackView2: LabeledFieldStackView!
        var fieldStackView3: LabeledFieldStackView!
        var fieldStackView4: LabeledFieldStackView!
    
        var stepper: UIStepper!
    
        var fieldAndStepperStack: UIStackView!
    
        var twoLineStack: UIStackView!
    
        var fieldAndStepperStackWidthConstraint: NSLayoutConstraint!
    
        // horizontal gap between elements on the same "line"
        var horizontalSpacing: CGFloat!
    
        // vertical gap between "lines"
        var verticalSpacing: CGFloat!
    
        // vertical gap between labels above text fields
        var labelToFieldSpacing: CGFloat!
    
        override func viewDidLoad() {
    
            super.viewDidLoad()
    
            view.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
    
            horizontalSpacing = CGFloat(2)
            verticalSpacing = CGFloat(8)
            labelToFieldSpacing = CGFloat(2)
    
            createIntializeViews()
            createInitializeStacks()
            fillStacks()
    
        }
    
        private func createIntializeViews() {
    
            fieldStackView1 = LabeledFieldStackView(with: "label 1", fieldText: "field 1", verticalGap: labelToFieldSpacing)
            fieldStackView2 = LabeledFieldStackView(with: "label 2", fieldText: "field 2", verticalGap: labelToFieldSpacing)
            fieldStackView3 = LabeledFieldStackView(with: "label 3", fieldText: "field 3", verticalGap: labelToFieldSpacing)
            fieldStackView4 = LabeledFieldStackView(with: "label 4", fieldText: "field 4", verticalGap: labelToFieldSpacing)
    
            stepper = UIStepper()
    
        }
    
        private func createInitializeStacks() {
    
            rootStack = UIStackView()
            fieldAndStepperStack = UIStackView()
            twoLineStack = UIStackView()
    
            [rootStack, fieldAndStepperStack, twoLineStack].forEach {
                $0?.translatesAutoresizingMaskIntoConstraints = false
            }
    
            // rootStack has spacing of horizontalSpacing (inter-line vertical spacing)
            rootStack.axis = .vertical
            rootStack.alignment = .fill
            rootStack.distribution = .fill
            rootStack.spacing = verticalSpacing
    
            // fieldAndStepperStack has spacing of horizontalSpacing (space between field and stepper)
            // and .alignment of .bottom (so stepper aligns vertically with field)
            fieldAndStepperStack.axis = .horizontal
            fieldAndStepperStack.alignment = .bottom
            fieldAndStepperStack.distribution = .fill
            fieldAndStepperStack.spacing = horizontalSpacing
    
            // twoLineStack has inter-line vertical spacing of
            //   verticalSpacing in portrait orientation
            // for landscape orientation, the two "lines" will be changed to one "line"
            //  and the spacing will be changed to horizontalSpacing
            twoLineStack.axis = .vertical
            twoLineStack.alignment = .leading
            twoLineStack.distribution = .fill
            twoLineStack.spacing = verticalSpacing
    
        }
    
        private func fillStacks() {
    
            self.view.addSubview(rootStack)
    
            // constrain rootStack Top, Leading, Trailing = 20
            // no height or bottom constraint
            NSLayoutConstraint.activate([
                rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20.0),
                rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20.0),
                rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20.0),
                ])
    
            rootStack.addArrangedSubview(fieldStackView1)
    
            fieldAndStepperStack.addArrangedSubview(fieldStackView2)
            fieldAndStepperStack.addArrangedSubview(stepper)
    
            twoLineStack.addArrangedSubview(fieldAndStepperStack)
            twoLineStack.addArrangedSubview(fieldStackView3)
    
            rootStack.addArrangedSubview(twoLineStack)
    
            // fieldAndStepperStack needs width constrained to its superview (the twoLineStack) when
            //  in portrait orientation
            // setting the priority to 999 prevents "nested stackView" constraint breaks
            fieldAndStepperStackWidthConstraint = fieldAndStepperStack.widthAnchor.constraint(equalTo: twoLineStack.widthAnchor, multiplier: 1.0)
            fieldAndStepperStackWidthConstraint.priority = UILayoutPriority(rawValue: 999)
    
            // constrain fieldView3 width to fieldView2 width to keep them the same size
            NSLayoutConstraint.activate([
                fieldStackView3.widthAnchor.constraint(equalTo: fieldStackView2.widthAnchor, multiplier: 1.0)
                ])
    
            rootStack.addArrangedSubview(fieldStackView4)
    
        }
    
        override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    
            super.traitCollectionDidChange(previousTraitCollection)
    
            if traitCollection.verticalSizeClass == .regular {
                fieldAndStepperStackWidthConstraint.isActive = true
                twoLineStack.axis = .vertical
                twoLineStack.spacing = verticalSpacing
            } else if traitCollection.verticalSizeClass == .compact {
                fieldAndStepperStackWidthConstraint.isActive = false
                twoLineStack.axis = .horizontal
                twoLineStack.spacing = horizontalSpacing
            } else {
                print("Unexpected")
            }
        }
    
    }
    

    And the results:

    enter image description here

    enter image description here