Haagenti
Haagenti

Reputation: 8144

iOS Autolayout adaptive UI problems

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)>

Upvotes: 9

Views: 2568

Answers (5)

JBJr
JBJr

Reputation: 109

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.

Upvotes: 1

Mike Sand
Mike Sand

Reputation: 2770

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()
}

Upvotes: 4

Sulthan
Sulthan

Reputation: 130082

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)

Upvotes: 0

Graham Perks
Graham Perks

Reputation: 23390

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)
}

Upvotes: 0

Samantha
Samantha

Reputation: 2289

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)            
    } 

Upvotes: 0

Related Questions