iOS Autolayout adaptive UI problems
Asked Answered
O

5

9

I want to change my layout depending on if the width is greater than the height. But I keep getting layout warnings. I watch both WWDC videos on adaptive layout which helped a lot but did not solve the problem. I've created a simple version of my layout in using the following code. This is only so people can reproduce my issue. Code is run on the iPhone 7 plus.

import UIKit

class ViewController: UIViewController {

    var view1: UIView!
    var view2: UIView!

    var constraints = [NSLayoutConstraint]()

    override func viewDidLoad() {
        super.viewDidLoad()

        view1 = UIView()
        view1.translatesAutoresizingMaskIntoConstraints = false
        view1.backgroundColor = UIColor.red

        view2 = UIView()
        view2.translatesAutoresizingMaskIntoConstraints = false
        view2.backgroundColor = UIColor.green

        view.addSubview(view1)
        view.addSubview(view2)

        layoutPortrait()
    }

    func layoutPortrait() {
        let views = [
            "a1": view1,
            "a2": view2
        ]

        constraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|[a1]|", options: [], metrics: nil, views: views)
        constraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|[a2]|", options: [], metrics: nil, views: views)
        constraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|[a1(450)][a2]|", options: [], metrics: nil, views: views)
        NSLayoutConstraint.activate(constraints)
    }

    func layoutLandscape() {
        let views = [
            "a1": view1,
            "a2": view2
        ]

        constraints += NSLayoutConstraint.constraints(withVisualFormat: "H:|[a1(a2)][a2(a1)]|", options: [], metrics: nil, views: views)
        constraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|[a1]|", options: [], metrics: nil, views: views)
        constraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|[a2]|", options: [], metrics: nil, views: views)
    NSLayoutConstraint.activate(constraints)
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
         super.viewWillTransition(to: size, with: coordinator)

         NSLayoutConstraint.deactivate(constraints)

         constraints.removeAll()

         if (size.width > size.height) {
             layoutLandscape()
         } else {
            layoutPortrait()
         }

     } 

}

When I rotate a couple times xcode logs the warnings. I think I do the layout switch to early because it is still changing but I already set the height in portrait to big or something. Does someone know what I do wrong?

Constraint warnings: (happens when going back from landscape to portrait)

[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:0x618000081900 V:|-(0)-[UIView:0x7ff68ee0ac40]   (active, names: '|':UIView:0x7ff68ec03f50 )>",
    "<NSLayoutConstraint:0x618000081d60 UIView:0x7ff68ee0ac40.height == 450   (active)>",
    "<NSLayoutConstraint:0x618000083cf0 V:[UIView:0x7ff68ee0ac40]-(0)-[UIView:0x7ff68ee0ade0]   (active)>",
    "<NSLayoutConstraint:0x618000083d40 V:[UIView:0x7ff68ee0ade0]-(0)-|   (active, names: '|':UIView:0x7ff68ec03f50 )>",
    "<NSLayoutConstraint:0x600000085d70 'UIView-Encapsulated-Layout-Height' UIView:0x7ff68ec03f50.height == 414   (active)>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x618000081d60 UIView:0x7ff68ee0ac40.height == 450   (active)>
Odonto answered 15/1, 2017 at 1:37 Comment(9)
Why doesn't you try anchor layout. --> Apple DocMarin
ios 8 needs to be supportedOdonto
yeah it is ios 8 supported & even i done in swift 3.x.Marin
You mean @available(iOS 9.0, *) open class NSLayoutAnchor<AnchorType : AnyObject> : NSObject??Odonto
Sorry I have made mistake, it is 9.x supportive. You are right. sorry :(Marin
You have done with VFL , even you can try with NSLayoutConstraints like this ios_auto_layouts, but code is in obj-c. I never recommend with VFL because device sets constraints according to itself and mostly shows errors like its happening with you. It is happened with me thats why I said, for others I can't say.Marin
The errors make sense to me. I try to add a view with height 450 while it is rotating and of course that doesn't fit. You're mixing auto layout and frame's which in my experience is never a good idea. This code does "work" but with that warning you can never be sure which constraint will be broken and how it will effect it all. So I just want to know how to do this very simple conceptOdonto
Why don't you just call setNeedsUpdateConstraints and update your constraints from updateConstraints? That should handle the conflicts with chained rotations.Serenata
@Serenata I tried it but still fails? Could you post an answer??Odonto
I
4

You are correct you are doing the constraint work too early. The <NSLayoutConstraint:0x600000085d70 'UIView-Encapsulated-Layout-Height' UIView:0x7ff68ec03f50.height == 414... is a system-created constraint for the height of the view controller's view in landscape - why it's 414, exactly iPhone 5.5 (6/7 Plus) height in landscape. The rotation hasn't started, the vertical constraints with the 450-height constraint conflict, and you get the warning.

So you have to add the constraints after the rotation has completed. viewWillLayoutSubviews is the logical place, as this gets called when the view size is ready but before anything appears on screen, and can save the system the layout pass it would have made.

However, to silence the warning you still need to remove the constraints in viewWillTransition(to size: with coordinator:). The system does new auto layout calculations before viewWillLayoutSubviews() is called, and you will get the warning the other way, when going from portrait to landscape.

EDIT: As @sulthan noted in the comment, since other things can trigger layout you should also always remove constraints before adding them. If [constraints] array was emptied there's no effect. Since emptying the array after deactivating is a crucial step it should be in its own method, so clearConstraints().

Also, remember to check for orientation in initial layout - you call layoutPortrait() in viewDidLoad() thought of course that wasn't the issue.

New/change methods:

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()

    // Should be inside layoutPortrait/Landscape methods, here to keep code sample short.
    clearConstraints() 

    if (self.view.frame.size.width > self.view.frame.size.height) {
        layoutLandscape()
    } else {
        layoutPortrait()
    }
}

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)

    clearConstraints()
}

/// Ensure array is emptied when constraints are deactivated. Can be called repeatedly. 
func clearConstraints() {
    NSLayoutConstraint.deactivate(constraints) 
    constraints.removeAll()
}
Introvert answered 19/1, 2017 at 3:36 Comment(2)
I think you should also always remove constraints in viewWillLayoutSubviews because there are situations when the view will layout while not transitioning.Serenata
@Serenata That's a good point. viewWillTransition(to size:with coordinator:) will get many of them but other things can trigger layout passes and removing constraints/emptying array actually should be called always before new constraints are added. I've updated my answer.Introvert
A
1

If the layout constraint problem is due to a fixed (or programmatically modified) width or height constraint conflicting with a UIView-Encapsulated-Layout-Width or UIView-Encapsulated-Layout-Height constraint, it's a problem of that unbreakable constraint causing your too-big, required (priority 1000) constraint to be tossed.

You can frequently fix the problem by simply lowering the priority of your conflicting constraint (perhaps just to 999) which will accommodate any temporary conflict until rotation is completed and the subsequent layout will proceed with the width or height you need.

Attainment answered 16/2, 2019 at 19:36 Comment(0)
S
0

As far as I can tell, the warning is actually because the constraint is being moved too late. The warning appears when rotating to landscape—the 450 constraint is still set and can’t be satisfied because the height of the view is too small.

I tried several things and what seemed to work best was setting the constraint on the smaller view instead, such that the larger view's height is still 450.

Rather than

constraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|[a1(450)][a2]|", options: [], metrics: nil, views: views)

I used

let height = view.frame.height - 450
constraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|[a1][a2(\(height))]|", options: [], metrics: nil, views: views)

and then in viewWillTransitionToSize:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    NSLayoutConstraint.deactivate(self.constraints)
    self.constraints.removeAll()

    coordinator.animate(alongsideTransition: { (context) in

        if (size.width > size.height) {
            self.layoutLandscape()
        } else {
            self.layoutPortrait()
        }
    }, completion: nil)

} 

Edit:

The other thing that worked for me (and perhaps a more general answer, although I'm not sure it's recommended): deactivating constraints before calling super.viewWillTransition and then animating the rest alongside the transition. My reasoning being that the constraints are removed immediately, technically before the rotation begins, and so once the app starts rotating to landscape (at which point the old height constraint would have been unsatisfiable), the constraints are already deactivated and the new ones can take effect.

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        NSLayoutConstraint.deactivate(constraints)
        super.viewWillTransition(to: size, with: coordinator)
        constraints.removeAll()

        coordinator.animate(alongsideTransition: { (context) in
            if (size.width > size.height) {
                self.layoutLandscape()
            } else {
                self.layoutPortrait()
            }

        }, completion: nil)            
    } 
Shutout answered 17/1, 2017 at 18:33 Comment(2)
I get your point. But this doesn't solve it completely. My example is simplified and you're answer doesn't solve how I can rotate and use another layout in portrait vs landscapeOdonto
I edited my answer to add one of the other (maybe more general) methods that worked for me. Is that more what you're looking for?Shutout
A
0

I came up with much the same answer as @Samantha. I didn't need to call deactivate before the call to super, however. This is an alternate using trait collections, but the transitionToSize approach works similarly.

override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
    super.willTransition(to: newCollection, with: coordinator)
    NSLayoutConstraint.deactivate(constraints)

    constraints.removeAll()

    coordinator.animate(alongsideTransition: { context in
        if newCollection.verticalSizeClass == .compact {
            self.layoutLandscape()
        } else {
            self.layoutPortrait()
        }
    }, completion: nil)
}
Ania answered 18/1, 2017 at 20:32 Comment(0)
S
0

This is the problematic part:

constraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|[a1(450)][a2]|", options: [], metrics: nil, views: views)

This line generates 4 constraints:

  1. top to a1 distance is 0.
  2. height of a1 is 450.
  3. a1 to a2 distance is 0.
  4. a2 to bottom distance is 0.

The four constraints together break in landscape mode because the overall height of the screen will be less than 450.

What you are doing now is to update constraints before you change to landscape mode and that works when you are switching from portrait to landscape. But when you are switching from landscape to portrait, you are changing your constraints too early and, for a short moment, you get portrait constraints while still in landscape, triggering that warning.

Note there is nothing essentially wrong about your constraints. If you want to fix your warning, the best solution in my opinion is to reduce the priority of one of your constraints, e.g. the height constraint:

constraints += NSLayoutConstraint.constraints(withVisualFormat: "V:|[a1(450@999)][a2]|", options: [], metrics: nil, views: views)
Serenata answered 18/1, 2017 at 21:5 Comment(1)
even though this solves the problem. I feel like Mike Sand's answer is the cleaner way to do this. Since I would need to add a lot of @ 999 to constraints. But thanks I learned a lotOdonto

© 2022 - 2024 — McMap. All rights reserved.