Proper way to invalidate collection view layout upon size change - ios

I'm implementing a collection view whose items are sized based on the bounds of the collection view. Therefore when the size changes, due to rotating the device for example, I need to invalidate the layout so that the cells are resized to consider the new collection view bounds. I have done this via the viewWillTransitionToSize API.
This works well until the user presents a modal view controller over the view controller that contains the collection view, rotates the device, then dismisses it. When that occurs the item size hasn't updated to the appropriate size. viewWillTransitionToSize is called and the layout is invalidated as expected, but the collection view's bounds is still the old value. For example when rotating from portrait to landscape the collection view bounds value still has its height greater than the width. I'm not sure why that's the case, but I'm wondering if this is the best way to invalidate upon size change? Is there a better way to do it?
I have also tried subclassing UICollectionViewFlowLayout and overriding shouldInvalidateLayoutForBoundsChange to return YES, but for some reason this doesn't work even rotating without a modal presentation. It doesn't use the proper collection view bounds either.
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(nonnull id<UIViewControllerTransitionCoordinator>)coordinator
{
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
[self.collectionView.collectionViewLayout invalidateLayout];
[coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> __nonnull context) {
[self.collectionView.collectionViewLayout invalidateLayout];
} completion:^(id<UIViewControllerTransitionCoordinatorContext> __nonnull context) {
//finished
}];
}
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewFlowLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
//collectionView.bounds.size is not always correct here after invalidating layout as explained above
}
I've also tried invaliding it in the completion block but it still doesn't use the proper collection view bounds.
If I invalidate the layout again in viewWillAppear, this does use the proper collection view bounds which resolves the issue with rotating with the modally presented view controller. But this shouldn't be necessary, perhaps there are other situations where this won't be sized properly.

I know what the problem is. When you call invalidateLayout inside animateAlongsideTransition (either in the animation block or the completion block), it doesn't actually recalculate the layout if there is a modal view controller presented over full screen (but it will if it's over current context). But it will invalidate it if you invalidate it outside of the animation block like I was doing. At that time however the collection view hasn't laid out for the new size, which explains why I was seeing the old value for its bounds. The reason for this behavior is invalidateLayout does not immediately cause the layout to be recalculated - it is scheduled to occur during the next layout pass. This layout pass does not occur when there's a modally presented view controller over full screen. To force a layout pass, simply call [self.collectionView layoutIfNeeded]; immediately after [self.collectionView.collectionViewLayout invalidateLayout];, still within the animateAlongsideTransition block. This will cause the layout to be recalculated as expected.

Related

After constraint update, when does UIView's bounds change?

In my view controller's viewDidLoad, I'm adjusting a UIImageView's constraints to reflect its image's aspect ratio. After it's done, I'd like to know the image view's updated bounds. The bounds do update in the UI, but when viewWillLayoutSubviews and viewDidLayoutSubviews are called (each is called only once), the bounds are still the original bounds from before I changed the aspect ratio. When should I check the bounds and make my associated changes?
After you update the constraints in the viewDidLoad, update them using either one below
[yourImageViewOL setNeedsDisplay];
[yourImageViewOL layoutIfNeeded]; //Allows you to perform layout before the drawing cycle happens. -layoutIfNeeded forces layout early
Then you can look for the updated bounds in the - (void)layoutSubviews; method as this method gets called by layoutIfNeeded automatically.
As of iOS 6.0, when constraints-based layout is used the base implementation applies the constraints-based layout, otherwise it does nothing.
Try to override the method -updateViewConstraints
- (void)updateViewConstraints {
[super updateViewConstraints];
/// your code here
}

Animating auto layout constraint change on UICollectionView

Following on from a previous SO question which I asked, I am attempting to animate the change in height of a UICollectionView (which is yellowBox in the example). This change in height is being triggered by modifying the auto layout constraints on the collection view with the following code:
- (IBAction)expandYellowBox:(id)sender {
[UIView animateWithDuration:0.5
animations:^{
self.yellowBoxHeightConstraint.constant += 50;
[self.view layoutIfNeeded];
}];
}
However, when I call [self.view layoutIfNeeded] this results in the collection view being reloaded, so it flashes visibly to the user. However, I don't want the collection view to reload but instead just to animate its height change. Is there any way to either avoid -layoutIfNeeded reloading the collection view, or an alternative way to animate the constraint height change which doesn't call a method which has the side-effect of reloading the collection view?
In case anyone else encounters this problem, it was because I was actually calling [self.collectionView reloadData] a few lines above the animation block (and didn't notice!). It seems that calling -reloadData and -layoutIfNeeded causes the collection view to flash. Removing the call to -reloadData resolves the problem.

UICollectionView rotation animation

I have two different UIViewControllers, and both of them have UiCollectionView.
Problems appears when I try rotate iPad.
Next I have in portrait position
When I rotate to landscape I reload my collectionView, so in lanscape mode I need have 3 columns
- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
{
[self.collectionView reloadData];
}
In first viewController all good, and when collectionView begin rotate controller still have 3 columns
But when I try do it in another controller, I get bad situation
It is looks like in one moment two collectionView in my view, and then one of them is disappearing.
I use standart UICollectionViewFlowLayout and have same methods for buils layout
Have you tried invoking the view..
(void)invalidateLayout
You can call this method at any time to update the layout information. This method invalidates the layout of the collection view itself and returns right away. Thus, you can call this method multiple times from the same block of code without triggering multiple layout updates. The actual layout update occurs during the next view layout update cycle.
Have you also tried this?
(void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation {
[self.collectionView reloadData];
}
I think I have solution, try this which understands when animating the rotation;
(void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:( NSTimeInterval)duration {
[super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration];
[self.collectionView reloadData];
}

UICollectionView and UINavigationController not cooperating — cells occluded by nav controller on setting prompt

In my UIViewContoller's subclass, ViewWillAppear asks whether or not there's any data to present, and if there is, changes the UINavigationController's prompt accordingly. This triggers an animation as the prompt pops into view, causing the UINavigationBar to grow in size. When this happens it partially occludes the cells in the top row of the UICollectionView.
I have a vertical Auto Layout constraint of 0, seemingly pinning the UICollectionView to its nearest neighbor, which should be its superview, but the navbar still blocks the top halves of the cells. I've tried everything — telling the CollectionView to layout its subviews, reloading data, etc., but nothing seems to work. Any idea what's going wrong?
- (void)viewWillAppear:(BOOL)animated{
if(self.orderedURLSet.count == 0){
self.navigationItem.prompt = nil;
[self.collectionView setNeedsDisplay];
} else {
self.navigationItem.prompt = #"Tap photos to edit";
}
[self.collectionView reloadData];
[self.collectionView layoutSubviews];
}
Edit: What makes this even stranger is that when I rotate orientation the collectionViewCells aren't occluded, and the full cells remain visible when I rotate back to portrait orientation. Is there some way I can "trick" my app into thinking its layout has changed and it needs to reposition the cells? LayoutSubviews isn't doing the trick.
Edit: After digging a little more into the UIView documentation, it looks like setNeedsLayout and layoutIfNeeded are really the methods I should be using, and not layoutSubviews. I've tried calling both of them, on navigationController:didShowViewController:animated:, viewWillAppear, viewDidAppear, and viewDidLayoutSubviews to no avail.
Have you tried??
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
return YES;
}

Getting the correct bounds of UIViewController's view

I have an iPad-application. In landscape orientation the UIViewController's view actual width = 1024px and height = 768 - 20 (statusBar) - 44 (navigationBar) = 704px.
So I wanna get this [1024 x 704] size and I'm using self.view.bounds for it. It returns [748 x 1024], which is wrong! But when I rotate the screen twice (current -> portrait -> current), the view's bounds are correct - [1024 x 704].
The view was initialized like this:
- (void)loadView {
self.view = [[UIView alloc] initWithFrame:[[UIScreen mainScreen] applicationFrame]];
self.view.backgroundColor = [UIColor lightGrayColor];
}
And bounds were get like this:
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(#"bounds = %#", NSStringFromCGRect(self.view.bounds));
}
- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation {
NSLog(#"bounds = %#", NSStringFromCGRect(self.view.bounds));
}
So the question is.. How can I get the correct view's bound in the very beginning?
How to do this correctly
Your UIViewController subclass should override the method viewWillLayoutSubviews, see also here.
When this method is called, the viewController's view has its correct size and you can make any necessary adjustments to subviews prior to the layout pass over the subviews.
Swift
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
NSLog("bounds = \(self.view.bounds)")
}
Obj-C
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
NSLog(#"bounds = %#", NSStringFromCGRect(self.view.bounds));
}
Documentation
When a view's bounds change, the view adjusts the position of its subviews. Your view controller can override this method to make changes before the view lays out its subviews. The default implementation of this method does nothing.
As you see from the emphasised part, this method is called every time the view controller's view changes size, as well as when the view first appears. This lets you respond correctly to rotation and other bounds change events
Several ways that don't work
A few approaches suggested in other answers don't work well, or have serious drawbacks. I urge you to avoid these approaches and I'll go through them to discuss the reasons you should avoid them.
viewDidLoad and viewWillAppear – during these calls view does not yet have its final size. The size you get will only ever be correct by pure chance, so as to mislead you.
viewDidAppear – this is too late. Your view is already on screen and visible to the user. Making changes here will cause visible changes / abrupt glitches and will look amateurish. Once again, please – for your sake, for my sake, for everyone's sake: don't do it! You're better than that and so are your users.
UIScreen.mainScreen.bounds.size – this is extremely low level. You're implementing a UIViewController and the size of its view depends on the controllers it is nested in (navigation, tab, paging, any custom controllers, etc), how the device is rotated, and potentially, how the screen has been split up for multitasking. So, while you might be able to compensate for all these and calculate the final size of your view, you'll end up with complex and brittle code that can easily break if Apple decide to change any of these metrics. UIViewController will do all this for you if you just override viewWillLayoutSubviews.
Other than not providing correct information, these problematic approaches will not help you with auto-rotation or other events that cause the view controller's view to change size, such as multitasking gestures. This is something you really want to handle smoothly.
So please: be a champ. Do it the right way. Use viewWillLayoutSubviews. Your implementation will be called for every size change, and your users, future self and team members will celebrate you for it. Bravo!
Further tips
When viewWillLayoutSubviews is called, the only view in your hierarchy that will be resized to its final size is viewController.view. The give away for this is in the name of the method. It's telling you view… (your view controller's root view) …WillLayout… (really soon now, but it's not happened yet) …Subviews (everything else in its hierarchy under the root view).
So subview layout has not happened yet. Every child under the root does not yet have a valid final size. Any size information you query from the child views will be at best completely wrong.
More likely, and enormously worse, it will be misleadingly correct.
It happens to be what you expect and need due to a default at this size and orientation, or due to your storyboard settings. But this is only by chance and isn't something you can rely on with different device sizes or orientations.
If you need to be told when a particular subview changes size, and know its exact final size, you should generally override the layoutSubviews method of that particular UIView subclass.
As per some of the other answers, the issue you are seeing is because viewDidLoad is called before the rotation happens. Because the iPad always initializes in portrait mode, if you get the size values in viewDidLoad, they will always be the portrait sizes - this is irrespective of any orientations you've configured.
To get the size after the orientation/rotation happens, get the size values in viewDidAppear.
I don't particularly understand why iOS doesn't handle this better - especially given that you define the orientations in the project settings, and, in Xcode Interface Builder. But, I'm sure there is a good reason ;-).
I've always used:
CGSize sizeOfScreen = [[UIScreen mainScreen] bounds].size;
to get the size of the screen, and:
CGSize sizeOfView = self.view.bounds.size;
to get the view's size. I have just tested it on viewDidLoad and it returned:
2012-07-17 10:25:46.562 Project[3904:15203] bounds = {768, 1004}
Which is correct since the CGSize is defined as {Width, Height}.
This is what I found in my last project: the frame of self.view will be adjusted after viewDidLoad according to if this screen has navigation bar etc.
So maybe you want to use that value after viewDidLoad (maybe in viewWillAppear or viewDidAppear) or adjust it manually by substract the height of bars.
I ran into this issue and found that getting the bounds in viewDidAppear worked for my needs.
If you want to get correct bounds of view in ViewDidLoad method, you can use Dispatch async with delay for it. See this example below:
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// Get your bounds here
}

Resources