Nested UIStackViews Broken Constraints
Asked Answered
K

7

41

I have a complex view hierarchy, built in Interface Builder, with nested UIStackViews. I get "unsatisfiable constraints" notices every time I hide some of my inner stackviews. I've tracked it down to this:

(
    "<NSLayoutConstraint:0x1396632d0 'UISV-canvas-connection' UIStackView:0x1392c5020.top == UILabel:0x13960cd30'Also available on iBooks'.top>",
    "<NSLayoutConstraint:0x139663470 'UISV-canvas-connection' V:[UIButton:0x139554f80]-(0)-|   (Names: '|':UIStackView:0x1392c5020 )>",
    "<NSLayoutConstraint:0x139552350 'UISV-hiding' V:[UIStackView:0x1392c5020(0)]>",
    "<NSLayoutConstraint:0x139663890 'UISV-spacing' V:[UILabel:0x13960cd30'Also available on iBooks']-(8)-[UIButton:0x139554f80]>"
)

Specifically, the UISV-spacing constraint: when hiding a UIStackView its high constraint gets a 0 constant, but that seems to clash with the inner stackview's spacing constraint: it requires 8 points between my Label and Button, which is irreconcilable with the hiding constraint and so the constraints crash.

Is there a way around this? I've tried recursively hiding all the inner StackViews of the hidden stack view, but that results in strange animations where content floats up out of the screen, and causes severe FPS drops to boot, while still not fixing the problem.

Knoxville answered 12/10, 2015 at 4:9 Comment(0)
A
47

This is a known problem with hiding nested stack views.

There are essentially 3 solutions to this problem:

  1. Change the spacing to 0, but then you'll need to remember the previous spacing value.
  2. Call innerStackView.removeFromSuperview(), but then you'll need to remember where to insert the stack view.
  3. Wrap the stack view in a UIView with at least one 999 constraint. E.g. top@1000, leading@1000, trailing@1000, bottom@999.

The 3rd option is the best in my opinion. For more information about this problem, why it happens, the different solutions, and how to implement solution 3, see my answer to a similar question.

Accede answered 7/7, 2016 at 4:57 Comment(1)
Solution 3 worked for me, I put the stack view inside a UIView constrained to all edges (like a normal one). It seems stack view doesnt like being the root view.Sawtelle
A
28

So, you have this:

broken animation

And the problem is, when you first collapse the inner stack, you get auto layout errors:

2017-07-02 15:40:02.377297-0500 nestedStackViews[17331:1727436] [LayoutConstraints] Unable to simultaneously satisfy constraints.
    Probably at least one of the constraints in the following list is one you don't want. 
    Try this: 
        (1) look at each constraint and try to figure out which you don't expect; 
        (2) find the code that added the unwanted constraint or constraints and fix it. 
(
    "<NSLayoutConstraint:0x62800008ce90 'UISV-canvas-connection' UIStackView:0x7fa57a70fce0.top == UILabel:0x7fa57a70ffb0'Top Label of Inner Stack'.top   (active)>",
    "<NSLayoutConstraint:0x62800008cf30 'UISV-canvas-connection' V:[UILabel:0x7fa57d30def0'Bottom Label of Inner Sta...']-(0)-|   (active, names: '|':UIStackView:0x7fa57a70fce0 )>",
    "<NSLayoutConstraint:0x62000008bc70 'UISV-hiding' UIStackView:0x7fa57a70fce0.height == 0   (active)>",
    "<NSLayoutConstraint:0x62800008cf80 'UISV-spacing' V:[UILabel:0x7fa57a70ffb0'Top Label of Inner Stack']-(8)-[UILabel:0x7fa57d30def0'Bottom Label of Inner Sta...']   (active)>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x62800008cf80 'UISV-spacing' V:[UILabel:0x7fa57a70ffb0'Top Label of Inner Stack']-(8)-[UILabel:0x7fa57d30def0'Bottom Label of Inner Sta...']   (active)>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKit/UIView.h> may also be helpful.

The problem, as you noted, is that the outer stack view applies a height = 0 constraint to the inner stack view. This conflicts with the 8 point padding constraint applied by the inner stack view between its own subviews. Both constraints cannot be satisfied simultaneously.

The outer stack view uses this height = 0 constraint, I believe, because it looks better when animated than just letting the inner view be hidden without shrinking first.

There's a simple fix for this: wrap the inner stack view in a plain UIView, and hide that wrapper. I'll demonstrate.

Here's the scene outline for the broken version above:

broken outline

To fix the problem, select the inner stack view. From the menu bar, choose Editor > Embed In > View:

embed in view

Interface Builder created a width constraint on the wrapper view when I did this, so delete that width constraint:

delete width constraint

Next, create constraints between all four edges of the wrapper and the inner stack view:

create constraints

At this point, the layout is actually correct at runtime, but Interface Builder draws it incorrectly. You can fix it by setting the vertical hugging priorities of the inner stack's children higher. I set them to 800:

hugging priorities

We haven't actually fixed the unsatisfiable constrain problem at this point. To do so, find the bottom constraint that you just created and set its priority to less than required. Let's change it to 800:

change bottom constraint priority

Finally, you presumably had an outlet in your view controller connected to the inner stack view, because you were changing its hidden property. Change that outlet to connect to the wrapper view instead of the inner stack view. If your outlet's type is UIStackView, you'll need to change it to UIView. Mine was already of type UIView, so I just reconnected it in the storyboard:

change outlet

Now, when you toggle the wrapper view's hidden property, the stack view will appear to collapse, with no unsatisfiable constraint warnings. It looks virtually identical, so I won't bother posting another GIF of the app running.

You can find my test project in this github repository.

Accuse answered 2/7, 2017 at 21:19 Comment(1)
Note also that you can easily change the animation effect using the wrapper view. Try turning on “Clips To Bounds” on the wrapper view and setting the bottom constraint's priority to 600. You get a nice slide-under effect instead of a squishing effect.Accuse
B
19

I hit a similar problem with UISV-hiding. For me, the solution was to reduce the priorities of my own constraints from Required (1000) to something less than that. When UISV-hiding constrains are added, they take priority and the constraints no longer clash.

Binder answered 15/10, 2015 at 7:36 Comment(4)
the problem is I have no constraints of my own on those elements :( however, this did resolve other similar issues in the case that I did.Knoxville
I had a similar problem and this fixed it for me.Feed
Holy cow! After a day of pondering, wondering, and some serious brainstorming, this was it. It makes sense that the UIStackView would operate on constraints that we don't see, but inherently conflict with our constraints. Great answer, thank you @Jaanus!Indigotin
Changing the priorities also worked for me. Also, removing any excess (dimmed) Constraints that were accidentally copied over from unused Size Classes. IMPORTANT TIP: to more easily debug these problems, set an IDENTIFER string on each Constraint. Then you can see which Constraint was being naughty in the debug message.Ardehs
M
17

Ideally we could just set the priority of the UISV-spacing constraint to a lower value, but there doesn't appear to be any way to do that. :)

I am having success setting the spacing property of the nested stack views to 0 before hiding, and restoring to the proper value after making it visible again.

I think doing this recursively on nested stack views would work. You could store the original value of the spacing property in a dictionary and restore it later.

My project only has a single level of nesting, so I am unsure if this would result in FPS problems. As long as you don't animate the changes in spacing, I don't think it would create too much of a hit.

Moyra answered 14/10, 2015 at 14:46 Comment(1)
thanks, I'll try that out today and accept the answer if it works :)Knoxville
F
2

Another approach

Try to avoid nested UIStackViews. I love them and build almost everything with them. But as I recognized that they secretly add constraints I try to only use them at the highest level and non-nested where possible. This way I can specify the 2nd highest priority .defaultHighto the spacing constraint which resolves my warnings.

This priority is just enough to prevent most layout issues.

Of course you need to specify some more constraints but this way you have full control of them and make your view layout explicit.

Fellah answered 2/11, 2017 at 14:57 Comment(0)
H
0

Here's implementation of Senseful's suggestion #3 written as Swift 3 class using SnapKit constraints. I also tried overriding the properties, but never got it working without warnings, so I'll stick with wrapping UIStackView:

class NestableStackView: UIView {
    private var actualStackView = UIStackView()

    override init(frame: CGRect) {
        super.init(frame: frame);
        addSubview(actualStackView);
        actualStackView.snp.makeConstraints { (make) in
            // Lower edges priority to allow hiding when spacing > 0
            make.edges.equalToSuperview().priority(999);
        }
    }

    convenience init() {
        self.init(frame: CGRect.zero);
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func addArrangedSubview(_ view: UIView) {
        actualStackView.addArrangedSubview(view);
    }

    func removeArrangedSubview(_ view: UIView) {
        actualStackView.removeArrangedSubview(view);
    }

    var axis: UILayoutConstraintAxis {
        get {
            return actualStackView.axis;
        }
        set {
            actualStackView.axis = newValue;
        }
    }

    open var distribution: UIStackViewDistribution {
        get {
            return actualStackView.distribution;
        }
        set {
            actualStackView.distribution = newValue;
        }
    }

    var alignment: UIStackViewAlignment {
        get {
            return actualStackView.alignment;
        }
        set {
            actualStackView.alignment = newValue;
        }
    }

    var spacing: CGFloat {
        get {
            return actualStackView.spacing;
        }
        set {
            actualStackView.spacing = newValue;
        }
    }
}
Heres answered 24/12, 2016 at 7:1 Comment(0)
P
0

In my case I was adding width and height constraint to a navigation bar button, as per the advice above I only added lower priority to the constraints.

open func customizeNavigationBarBackButton() {
        let _selector = #selector(UIViewController._backButtonPressed(_:))
        let backButtonView = UIButton(type: .custom)
        backButtonView.setImage(UIImage(named: "icon_back"), for: .normal)
        backButtonView.imageEdgeInsets = UIEdgeInsets.init(top: 0, left: -30, bottom: 0, right: 0)
        backButtonView.snp.makeConstraints { $0.width.height.equalTo(44).priority(900) }
        backButtonView.addTarget(self, action: _selector, for: .touchUpInside)

        let backButton = UIBarButtonItem(customView: backButtonView)
        self.navigationItem.leftBarButtonItem = backButton
    }
Palmira answered 29/8, 2020 at 14:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.