layoutIfNeeded sequence acts differently on iPad versus iPhone; how to fix? - ios

Fire up Xcode and for clarity build only to say 9.3, universal app. So, compare 9.3 iPads with 9.3 iPhones. Build to both simulator and devices - issue exhibits on both.
The app rotates in all four directions.
Have a typical situation where you do something like this...
#IBOutlet weak var doorHeightPerScreen: NSLayoutConstraint!
var heightFraction:CGFloat = 0.6
{
didSet
{
if ( heightFraction > maxHeight ) { heightFraction = maxHeight }
if ( heightFraction < minHeight ) { heightFraction = minHeight }
let h = view.bounds.size.height
spaceshipHeightPerScreen.constant = h * heightFraction
self.view.layoutIfNeeded() // holy! read on....
}
}
Notice the layoutIfNeeded() after the change to the constraint.
Continuing the typical example, you will have something like
override func viewDidLayoutSubviews()
{
super.viewDidLayoutSubviews()
heightFraction = (heightFraction)
// use "autolayout power" for perfection every pass.
// now that basic height/position is set,
save/load reactive positions...
position detail stuff...
}
Check it out ... I was doing this all day and only happened to use iPhones.
Interestingly you do not need the layoutIfNeeded call:
#IBOutlet weak var doorHeightPerScreen: NSLayoutConstraint!
var heightFraction:CGFloat = 0.6
{
didSet
{
if ( heightFraction > maxHeight ) { heightFraction = maxHeight }
if ( heightFraction < minHeight ) { heightFraction = minHeight }
let h = view.bounds.size.height
spaceshipHeightPerScreen.constant = h * heightFraction
}
}
Works fine.
However at the end of the day I put it on some iPads and .... everything broke!
Whenever you rotate landscape/portrait, problems.
After a head scratch, I realized that incredibly you do need the layoutIfNeeded call, on iPad. That's on the identical OS.
Indeed the behavior exhibits regardless of OS version. And it exhibits for ALL iPhones / ALL iPads.
#IBOutlet weak var doorHeightPerScreen: NSLayoutConstraint!
var heightFraction:CGFloat = 0.6
{
didSet
{
if ( heightFraction > maxHeight ) { heightFraction = maxHeight }
if ( heightFraction < minHeight ) { heightFraction = minHeight }
let h = view.bounds.size.height
spaceshipHeightPerScreen.constant = h * heightFraction
self.view.layoutIfNeeded() //MUST HAVE, IN IPAD CASE!!!!!!
}
}
To me it is incredibly troubling that they would work differently.
What I'm wondering is, is there perhaps a setting somewhere to make them work the same? Could it be my fault somehow?
Are there any other know differences between the two - or indeed is it "known" that there are a few bugs like this?
I can't think of anything odd or unusual I did anywhere, except the whole app has override func supportedInterfaceOrientations() -> UIInterfaceOrientationMask { return .All } in the first view as is normal if you want to turn the device upside down; I doubt it's related. Other than that it's a very "clean" fresh app.
It gave me a glitch-in-the-matrix feeling - it was terrifying.
What could cause this?
Per RobM's question, the SimulatedMetrics settings (Attributes tab) on the initial ViewController are...
General scheme of the app: the first scene "General" is full-screen, the size of the device. There's a container to "Live" which is the same size (using "Trailing" etc/ constraints as zero all round). In Live, there's a container view "Quad" which indeed is also fully sized to "Live," so it's also fullscreen. Quad:UIViewController exhibits the issue I describe. Quad contains various objects (images, custom controls etc) which sit around on the view. When the app launches, all is fine.
On rotation of the device (or similar): just after the change to the constraint (I don't know if that's relevant): the layoutIfNeeded call IS needed for iPads (all iPads), but is NOT needed for iPhones (all iPhones). The behavior is identical in the simulator and on devices.
Another example
I found another astounding example of this.
In a UICollectionView, custom cells (just simple static sized cells). If you happen to change a constraint (imagine say resizing an icon or product shot within the cell).
On iPad you do have to be sure to readjust in layoutIfNeeded or it will not work on the first appearance of the cell.
Whereas on iPhone it definitely behaves differently: it will "do that for you", before the first appearance of the cell, if you happen to omit it.
I tested that on every iPad and every iPhone. (Also, the unusual behavior exhibits exactly on devices or simulators: simulator makes no difference.)

I'm not able to reproduce what you're seeing; it would be nice to see a complete example. In my mockup I configured a view controller with a view having a single subview, with a constraint to control the subview height. I altered the subview height constraint in viewDidLayout based on the view size. The behavior was identical for both iPhone and iPad, and worked sans calling layoutIfNeeded on any view.
That said, I think you're changing subview constraints once the view has completed its layout - yes? I think the better way to do that would be to layout your subviews ahead of that, via viewWillTransitionToSize:withTransitionCoordinator:.
func viewWillTransitionToSize(_ size: CGSize,
withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
This way auto layout for the view hierarchy can complete in a single pass. This method is only called when the view is changing size, so it won't be called when the view is first loaded; you'll have to set up your initial constraints somewhere else - since they're dependent on view size perhaps you can use viewWillAppear.
Alternatively, (and possibly more correctly), subclass your view controller's view and override updateConstraints. This is the most appropriate place for changing your constraint constants.
Finally, in your property setter, you shouldn't ever call view.layoutIfNeeded(). If anything, you can set view.setNeedsLayout() so that layout happens in the next runloop iteration, and picks up ALL changes that may need to be represented.

The default simulated metrics size is “inferred”, which (if the scene isn't the target of a segue or relationship) gives you a 600x600 view, which doesn't correspond to the screen size of any iOS device. You changed the simulated metrics size at some point to “iPhone 5.5-inch”, probably to match the size of your main test device.
When a view is loaded from a storyboard (or xib), it's loaded at the size it had in the storyboard. It may then be resized by its container (either the UIWindow if it's the root view of the app, or by its superview if it's the root view of a contained view controller).
In your case, it sounds like your main test device's screen has the same size as the root view in your storyboard, so the test device doesn't have to run as much layout as you might expect.
When you use a test device whose screen size differs from your root view's size in the storyboard, the test device has to do more layout.
I didn't try to reproduce your problem, so I'm not claiming that this is a full explanation of what you're seeing. There may well be an iOS bug involved. Nevertheless, this should explain why your app behaves differently on different devices. I believe this is also why Apple chose the default inferred size of 600x600: since no device screen is that size, all devices will have to do the same amount of layout.

Related

Change constraint when device is rotated

I'm using the following layout as a custom popup UIView in Xcode 13 (the white background is transparent):
When the screen orientation is changed to landscape mode, the constraint at the top and bottom are still 100pts. Because of that the middle part (yellow, UIView with UIStackView with UITableView,... inside) is really small and a warning shows up in console about the top (red) and bottom (blue) bar:
Unable to simultaneously satisfy constraints.
I know what this warning means. To fix it I created the following function...
private let constraintPortrait:CGFloat = 100
private let constraintLandscape:CGFloat = 10
private func fixConstraints() {
if (UIDevice.current.orientation == .landscapeLeft || UIDevice.current.orientation == .landscapeRight) && UIDevice.current.userInterfaceIdiom == .phone {
topConstraint.constant = constraintLandscape
bottomConstraint.constant = constraintLandscape
} else {
topConstraint.constant = constraintPortrait
bottomConstraint.constant = constraintPortrait
}
}
... and call it both in viewDidLoad and viewDidLayoutSubviews. This was working great but every now and then the warning still popped up, so I added prints to viewDidLoad,... and noticed that the warning is actually printed before my constraint fix is called. I renamend viewDidLayoutSubviews to viewWillLayoutSubviews (UIViewController lifecycle here) and Abracadabra!, the warning was gone.
People usually recommend to use viewDidLayoutSubviews when you want to do stuff after the device was rotated but hardly ever mention viewWillLayoutSubviews and while searching for a reason for that I found this answer, saying not to use the latter to change constraints because it might cause another autolayout pass.
Question:
What should I use instead to prevent the conflicts (without changing the fixed constraints for portrait mode!)? Is there a way to change the top and bottom constraint automatically and solely in the Interface Builder, without using any code and only when actually necessary (-> always keep the 100pts in portrait mode, even with a long table, but switch to 10pts instantly in landscape mode when there isn't enough space)?
viewWillLayoutSubviews is correct. Any layout changes you perform here, including changes of constraints, will be animated automatically in coordination with the rotation animation.
But how will you know that this call to viewWillLayoutSubviews is due to rotation? Implement this method:
https://developer.apple.com/documentation/uikit/uicontentcontainer/1621466-viewwilltransition
Or, on an iPhone, this method:
https://developer.apple.com/documentation/uikit/uicontentcontainer/1621511-willtransition
I like the former because it works on both iPad and iPhone. These are called before viewWillLayoutSubviews, so you can set an instance property to signal to yourself that the size is officially changing. You can work out what's happening by comparing the bounds size height to the bounds size width, and change the constraints accordingly.

Swift button frame height issue (viewDidLayoutSubviews)

I've got some square buttons that I'd like to add rounded corners to that are proportional to the button's height. In past versions of my app, I had implemented this feature without issues using viewDidLayoutSubviews(). For some reason, after pushing a new version of my app with other features I had tweaked, this section of code no longer functions as expected. Here is the code:
override func viewDidLayoutSubviews() {
for button in buttons {
button!.layer.shadowColor = UIColor.black.cgColor
button!.layer.shadowOffset = CGSize(width: 0, height: 1.0)
button!.layer.shadowOpacity = 0.4
button!.layer.shadowRadius = button!.frame.height / 40
button!.layer.cornerRadius = button!.frame.height / 10
}
Again, this block of code used to work just fine but for some reason it no longer works. What I am experiencing is much larger relative radii on smaller buttons (iPhone SE) compared to bigger buttons (iPads).
To troubleshoot, in viewDidLayoutSubviews(), I'm printing the button!.frame.height and I'm noticing that no matter what device I use the frame height is 395.5, which I believe is the correct size only on the 12.9" iPad. Therefore, the buttons look correct on the 12.9" iPad but the radii end up being too large on all of the smaller devices.
Any idea what's going on here? Why is it that they're all returning the same frame height even though they're visually very different sizes on the different devices?
I copy and pasted the above code into the viewWillAppear() method and
the problem was resolved. I then deleted the code from
viewWillAppear(), leaving me with my original code during posting of
question, and it is continuing to run as expected (working). What
could possibly be the cause of this intermittent behavior
The reason when you initialized the buttons in viewWillAppear and remove them but it still work because your button's frame did not change in the viewDidLayoutSubview method. And the viewDidLayoutSubview is invoked only controller's view is updated, rotated, or changed, which in your case it does not.
If you try to rotate your device you will see your parent view's frame changed.
For more information about view hierarchy. See this article
Try like this:-
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
for button in buttons {
button!.layer.shadowColor = UIColor.black.cgColor
button!.layer.shadowOffset = CGSize(width: 0, height: 1.0)
button!.layer.shadowOpacity = 0.4
button!.layer.shadowRadius = button!.frame.height / 40
button!.layer.cornerRadius = button!.frame.height / 10
}

How do I add constraints so that my view's dimensions do not change when the orientation changes?

I want my view to have the following properties (the numbers are arbitrarily chosen):
width is equal to height divided by 1.2
stays at the bottom right of the screen
height is 1/7 of the screen's height when in portrait
width and height does not change when the orientation changes
The first three requirements can be easily translated into UILayoutConstraints. I have done them with SnapKit just because it reads more clearly. You should see what I mean even if you have never used SnapKit before.
let myView = UIView(frame: .zero)
myView.backgroundColor = .green
view.addSubview(myView)
myView.snp.makeConstraints { (make) in
make.right.equalToSuperview().offset(-8)
make.bottom.equalToSuperview().offset(-8)
make.width.equalTo(myView.snp.height).dividedBy(1.2)
make.height.equalTo(view.snp.height).dividedBy(7) // *
}
The problem is the last bullet point. When I rotate the device from portrait to landscape, what was originally the width before the rotation, becomes the height after the rotation. This causes my view to become smaller as a result.
Basically, I want to replace the constraint marked with * with something like this:
make.height.equalTo(max(view.snp.height, view.snp.width)).dividedBy(7)
but I don't think max(a, b) is a thing in SnapKit or the UILayoutConstraint API.
Surely there is some other way of expressing "equal to whichever length is longer", right?
P.S. I didn't tag this with snapkit because I would also accept an answer that uses the UILayoutConstraint API.
Looks like you have 2 options:
Hardcode the height value.
Try to use nativeBounds:
This rectangle is based on the device in a portrait-up orientation. This value does not change as the device rotates.
In this case the height is always be for portrait mode.
myView.snp.makeConstraints { make in
make.right.bottom.equalToSuperview().offset(-8)
let screenHeight = UIScreen.main.nativeBounds.height / UIScreen.main.nativeScale
let height = screenHeight / 7
make.width.equalTo(height).dividedBy(1.2)
make.height.equalTo(height)
}

updated constraints in traitCollectionDidChange have no effect

With my autolayout, I am trying to implement a special case for the smallest screens, when my icons need to be smaller. I am changing the constraint for the icons in traitCollectionDidChange(). However, the change has no effect. I've tried adding SetNeedsLayout, SetNeedsDisplay, UpdateConstraints, etc, and nothing works.
If I change these constraints when the view is initialized, then the change works -- but that doesn't help me when the user rotates the device and I need the change to happen again. However, it's interesting to note that traitCollectionDidChange() is called at the launch of the program, but if I make the constraint changes within that function, they don't work. They only work if I set the new constraints in the init() function of the view.
It could be that this function is called AFTER the constraints are used, except Apple tells us to use this function for just this purpose, to change layouts etc when rotating. I don't want to use WillTransitionToSize because only the ViewController is called with that function, not views. But I will probably try that next.
traitCollectionDidChange() is definitely being called, at startup and when I rotate. It's just that my changes don't do anything. Any ideas?
Any ideas?
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// smaller icon constraints for iPhone 5/SE
if let constraint = (self.imageView!.constraints.filter{$0.firstAttribute == .width}.first) {
print("cwide \(constraint.constant)")
if UIScreen.main.bounds.size.width <= 320 {
constraint.constant = 20.0
self.radius = 4
}
else {
constraint.constant = 32.0
self.radius = 8
}
}
self.updateConstraints()
}
I had a similar feature and i turned out that I had forgotten to install the constraints in the Interface Builder.

How to debug layout with Multiline UILabel / autolayout in notification content extension

How to debug the following issue? Is there a way how to work around this issue?
There seems to be a bug in iOS 10.2 and below when laying out a multi-line UILabel.
I have a fairly simple UIView subclass which I use in both app and notification content extension, that looks like this:
In the main app, everything is laid out just fine:
When shown in notification content extension on iOS 10.2 and below, the layout is broken. But only when the text is long enough to be broken into multiple lines. Seems like iOS can't calculate correct height of the whole view:
However, this issue seems to be fixed on iOS 10.3 and newer:
I started experimenting with the subviews, specifically by setting fixed height constraints.
Turns out, it was not the label(s) that caused the issue with calculating overall height but the aspect ratio constraint (width:height) on the topmost view.
Programmatically calculating height based on the view's width and setting a height constraint for the affected view helped to fix the issue:
public override func updateConstraints() {
super.updateConstraints()
if #available(iOS 10.2, *) {
imageContainerHeightConstraint.isActive = false
} else {
// FIX: multiline label / aspect ratio / autolayout bug in iOS < 10.2
let ratio: CGFloat = imageContainerAspectRatioConstraint.multiplier
imageContainerHeightConstraint.constant = round(bounds.width/ratio)
imageContainerHeightConstraint.isActive = true
}
}

Resources