Here is my problem: rotate an iPhone 6 Plus or an iPad to e.g. LandscapeLeft.
Lets say your app has the following code (only for example)
override func supportedInterfaceOrientations() -> UIInterfaceOrientationMask {
return UIInterfaceOrientationMask.Portrait
// mine does use All, but with this case you'll understand my question
}
How do I detect if the device should use vertical or horizontal layout when
func willTransitionToTraitCollection(newCollection: UITraitCollection, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
is called?
print(UIScreen.mainScreen().bounds.width < UIScreen.mainScreen().bounds.height)
// will return false, because we started in landscape but the layout will be forced to be vertical here
print(UIApplication.sharedApplication().statusBarOrientation == .LandscapeLeft)
// will return true, because we started in landscape but the layout will be forced to be vertical here
The main problem appears on the iPad, because both size classes are regular.
I'm confused and have no idea how to solve this issue.
Don't think of your layouts as vertical and horizontal. Just let the app rotate, and respond to the size class or the size change.
On the iPad, try to avoid having two layouts. Just use autolayout to rejigger the proportions automatically. But if you must respond to rotation on the iPad, then respond to viewWillTransitionToSize (because the trait collection won't change).
Related
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.
I have the following code:
public override void TraitCollectionDidChange(previousTraitCollection: UITraitCollection)
{
base.TraitCollectionDidChange(previousTraitCollection)
...
}
What does base.TraitCollectionDidChange() do?
This methods will be called when your size class change. So you can adapt your layout :)
Have you heard about size class ?
Size class are an apple abstraction to define a size device.
For example to Iphone X have, that values :
For Portrait :
- Compact width, regular height
For Landscape :
- Compact width, compact height
With TraitCollection you can identify when this classes changes, for example from Portrait to Landscape, with this information you can update your layout.
Here an tutorial:
https://www.hackingwithswift.com/example-code/uikit/how-to-detect-when-your-size-class-changes
Here an tutorial about size class:
https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/
Currently, on iPad, my UINavigationBar large title looks strange because it does not indent to where my UITableView cells are. This is because my table view and cells follow readable width. Is there a way of indenting this title or is the only piece of advice to align the cells with the title (make them not follow readable width). I would really appreciate anyone who took the time to respond.
When you first load your view controller, you should retrieve the minX coordinate of the current readableContentGuide and set that value to the left layoutMargin value. As so:
let leftMargin = self.view.readableContentGuide.layoutFrame.minX
self.navigationController?.navigationBar.layoutMargins.left = leftMargin
Then, you'll also want to make sure your layout adjusts dynamically so that if the user rotates their screen (or on an iPad, is using a variable window size) the title will remain aligned:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
let leftMargin = view.readableContentGuide.layoutFrame.minX
navigationController?.navigationBar.layoutMargins.left = leftMargin
}, completion: nil)
}
The answer of #swillsea did not work correctly for me: on rotation of the device the left margin of the large title seems to have a wrong initial position during the rotation animation. One can see this in the simulator with the options for slow animations enabled.
The reason for this is that the screen is laid out in the new dimension using the old left margin. During the rotation animation the margin is animated into the correct final size.
Instead of updating the left margin when the size changes I simply update as part of the normal layout chain:
override func viewWillLayoutSubviews() {
let leftMargin = self.view.readableContentGuide.layoutFrame.minX
self.navigationController?.navigationBar.layoutMargins.left = leftMargin
}
The effect for me is now, that the left margin of the title is now equal to that of my readable content in all phases of the rotation animation.
I need this grid layout, view structure with Storyboard. Is it an easier way to set up, or I need to calculate size / 4, and multiply it by the index, and calculate the center X, Y coordinates, and adjust NSLayoutConstraint at each rotation?
You can use UIStackView with default vertical and change it's axis to horizontal in landscape size class , with distribution Fill-Equally
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
if UIDevice.current.orientation == .portrait
{
self.stackV.axis = .vertical
}
else
{
self.stackV.axis = .horizontal
}
}
The above answer is perfect but we can also handle it by using storyboard.To explain I get 3 UIView into a stack view with below property.
Now when I go to landscape from portrait mode the height of the screen is converting to compact from regular.So we can change stack view axis from vertical to horizontal according to height change of the screen.below gif explain you visually.
for further information you can visit this link.
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.