0

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

Dave Largent
  • 27
  • 11
  • Why are you adding a leading/trailing constraint for the two text fields? Pretty sure that is what's causing the constraint issue. – DonMag Jan 28 '19 at 19:40
  • @DonMag In the full implementation, I've several rows of various controls. It is a form the user fills out using textfields, stepper, and segmented controls. When not vertically constrained, the textfield controls of each row are aligned along their trailing edges. – Dave Largent Jan 29 '19 at 00:25
  • Looking at your "More of the form" image... it looks like it would be Portrait Orientation? If so, how do you want it to look in Landscape? Or, is that Landscape, and you want the Stepper to move below TextField-2 in Portrait? – DonMag Jan 29 '19 at 13:18
  • In landscape orientation, TextField 2, the Stepper, and TextField 3 are on the same row in that order. I updated the original post with a picture of the landscape orientation. – Dave Largent Jan 31 '19 at 12:35
  • Is this what you're trying to get? https://imgur.com/a/L6KB0Tl – DonMag Jan 31 '19 at 17:03
  • I have a little space between controls, otherwise, you nailed it. If I can get what you've shown working without constraint violations and with some space, I have a few more controls that I haven't presented yet. For my post, I tried to break the problem down to the least number of controls and figured I could extrapolate when I understood what I was doing. – Dave Largent Feb 01 '19 at 03:51

2 Answers2

0

When a UIView is added to a UIStackView, the stackView will assign constraints to that view based on the properties assigned to the stackView (axis, alignment, distribution, spacing). As mentioned by @DonMag you are adding a constraint to the textField's in the aggregateStack view. The aggregateStack will add its own constraints based on it attributes. By removing that constraint and the activation/deactivation code the constraint conflict goes away.

I created a small example using your code and adding some background views to the stackViews so you can see more easily what is happening when you change the various properties. Just for illustration I pinned the rootStackView to the edges of the view controller's view, just so it would be visible.

import UIKit

class StackViewController: UIViewController {

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

    private lazy var backgroundView: UIView = {
        let view = UIView()
        view.backgroundColor = .purple
        view.layer.cornerRadius = 10.0
        return view
    }()

    private lazy var otherBackgroundView: UIView = {
        let view = UIView()
        view.backgroundColor = .green
        view.layer.cornerRadius = 10.0
        return view
    }()

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

    private func createIntializeViews() {

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

    private func createInitializeAddStacks() {

        rootStack = UIStackView()
        rootStack.alignment = .center
        rootStack.distribution = .fillProportionally
        pinBackground(backgroundView, to: rootStack)

        aggregateStack = UIStackView()
        aggregateStack.alignment = .center
        aggregateStack.distribution = .fillProportionally
        pinBackground(otherBackgroundView, to: aggregateStack)

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

        field1.translatesAutoresizingMaskIntoConstraints = false
        field2.translatesAutoresizingMaskIntoConstraints = false

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

        view.addSubview(rootStack)
        rootStack.translatesAutoresizingMaskIntoConstraints = false

        /**
         * pin the root stackview to the edges of the view controller, just so we can see
         * it's behavior
         */
        NSLayoutConstraint.activate([
            rootStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant:16),
            rootStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant:-16),
            rootStack.topAnchor.constraint(equalTo: view.topAnchor, constant:32),
            rootStack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant:-32),
            ])
    }

    /**
     * Inserts a UIView into the UIStackView's hierarchy, but not as part of the arranged subviews
     * see https://useyourloaf.com/blog/stack-view-background-color/
     */
    private func pinBackground(_ view: UIView, to stackView: UIStackView) {
        view.translatesAutoresizingMaskIntoConstraints = false
        stackView.insertSubview(view, at: 0)
        NSLayoutConstraint.activate([
            view.leadingAnchor.constraint(equalTo: stackView.leadingAnchor),
            view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor),
            view.topAnchor.constraint(equalTo: stackView.topAnchor),
            view.bottomAnchor.constraint(equalTo: stackView.bottomAnchor)
            ])
    }

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

        super.traitCollectionDidChange(previousTraitCollection)

        switch traitCollection.verticalSizeClass {
        case .regular:
            aggregateStack.axis = .vertical
        case .compact:
            aggregateStack.axis = .horizontal
        case .unspecified:
            print("Unexpected")
        }
    }
}

enter image description here

Dennis W.
  • 646
  • 6
  • 7
  • I have trailing edge aligned controls because I knew of no other way to align the edges when I have a form with multiple controls on each row. – Dave Largent Jan 29 '19 at 00:34
  • What strikes me is how is it that when I first load the view there are no constraint conflicts? I then rotate the device and there are no constraint conflicts. But returning the device to the initial not vertically constrained orientation then show constraint conflicts. It appears to me that the application of stackview and nonstackview constraints gets convoluted. It's as though the system's constraint checking code if not taking in to account that I changed the stackview axis to vertical. The wtfautolayout output indicates there is a lingering horizontal constraint after rotation. – Dave Largent Jan 29 '19 at 00:39
  • Just to be sure, I almost always assume something about one of my assumptions or understanding is wrong. I do believe there is some way I am not thinking about stackviews that needs to change. Is it that I should use stackviews or view constraints and not mix the two? – Dave Largent Jan 29 '19 at 00:43
  • I love `UIStackView` but they can get tricky when you start nesting them. As I mentioned, they add their own constraints which can conflict with any constraints you add. You might need to add constraints to a view to set its size, for example, if the `UIView` does not have an intrinsic content size (like `UITextFields` or `UILabel` do), which `UIStackView` uses to apply things like `fillEqually`. I try to let the `UIStackView` do most of the layout work. – Dennis W. Jan 29 '19 at 00:59
  • Regarding 'can get tricky when you start nesting them'. I agree. It seems my form is simple. Nesting stackviews and having no constraint conflicts however has proven somewhat labor intensive. It's my hope to find a handful of patterns I can apply to be more efficient at producing forms. – Dave Largent Jan 31 '19 at 13:00
0

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

DonMag
  • 69,424
  • 5
  • 50
  • 86
  • After reading many articles and much trial and error, the notes you posted succinctly brought together the key issues I'd seen or suspected. Your example works excellently. Thank you. – Dave Largent Feb 03 '19 at 13:17