Stussa
Stussa

Reputation: 3415

UICollectionView Invalidate Layout On Bounds Changes

I currently have the following snippet for calculating UICollectionViewCells sizes:

- (CGSize)collectionView:(UICollectionView *)mainCollectionView
                  layout:(UICollectionViewLayout *)collectionViewLayout
  sizeForItemAtIndexPath:(NSIndexPath *)atIndexPath
{
    CGSize bounds = mainCollectionView.bounds.size;
    bounds.height /= 4;
    bounds.width /= 4;
    return bounds;
}

This works. However, I'm now adding a keyboard observer in viewDidLoad (which is triggering the the delegate and data source methods for the UICollectionView before it appears and resizes itself from the storyboard). The bounds are thus wrong. I also would like to support rotation. What's a good way of handling these two edge cases and re-calculating the sizes if the UICollectionView changes size?

Upvotes: 24

Views: 35081

Answers (5)

Rui L
Rui L

Reputation: 271

Steps to resize/relayout items using FlowLayout on UICollectionView's bounds changes, or orientation changes.

  1. Subclass UICollectionViewFlowLayout
  2. Override shouldInvalidateLayout(forBoundsChange:) to return true. You use this method to determine whether this layout should automatically call invalidateLayout(with:) once when specific bounds changes happen. Base class UICollectionViewLayout always returns false, that is, not to invalidate anything when bounds changed, however, UICollectionViewFlowLayout returns true sometimes to get its own jobs done. To perverse existing functionalities, You call super at first, and do your extra work only if super returns false.
  3. Override invalidateLayout(with:) to control what to invalidate exactly. The context parameter manages all the detail about this layout. For FlowLayout, it provides additional controls including invalidateFlowLayoutDelegateMetrics (whether to resize items, that is, recompute the sizes of items by calling collectionView(:layout:sizeForItemAt:)), and invalidateFlowLayoutAttributes(whether to relayout items, like, recompute the positions of items). You manipulate this context before calling super.

Sample code (resize and relayout items on width changes).

final class FlowLayout: UICollectionViewFlowLayout {
    
    /// A cache for `newBounds` parameter in `shouldInvalidateLayout(forBoundsChange:)`.
    var bounds: CGRect?
    
    /// Should we invalidate both size and layout information for next layout.
    var invalidatesAll = false
    
    override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
        guard let flowContext = context as? UICollectionViewFlowLayoutInvalidationContext else {
            return
        }
        if invalidatesAll {
            // Tell layout to recompute the size of items
            flowContext.invalidateFlowLayoutDelegateMetrics = true
            // Tell layout to recompute the layout of items
            flowContext.invalidateFlowLayoutAttributes = true
            // Reset this flag, because we do this only if there is a width change.
            invalidatesAll = false
        }
        super.invalidateLayout(with: flowContext)
    }
    
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        if super.shouldInvalidateLayout(forBoundsChange: newBounds) {
            // If super (flowlayout) has already determined an invalidation, we simply hand over.
            return true
        }
        
        // We make sure there is a width change to avoid unnecessary invalidations.
        // Note: for the first time, `bounds` is always nil and flow layout always do first layout automatically, so we want to avoid this too.
        let isWidthChanged = bounds != nil && newBounds.size.width != bounds?.size.width
        if isWidthChanged {
            // A width change happens!, and we want to recompute both size and layout on width change.
            invalidatesAll = true
        }
        bounds = newBounds
        return isWidthChanged
    }
}

Upvotes: 1

Michael
Michael

Reputation: 1285

Although @tubtub's answer is valid, some of you may experience the following error: The behaviour of the UICollectionViewFlowLayout is not defined.

First of all, remember to override the shouldInvalidateLayout within your CustomLayout class. (example below)

You should then consider if all the elements within your view's have changed its sizes, according to a new layout (see he optional steps within the example code).

Here is the following code, to get you started. Depending on the way your UI is created, you may have to experiment to find the right view to call the recalculate method, yet that should guide you towards the first steps.

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

    super.viewWillTransition(to: size, with: coordinator)

    /// (Optional) Additional step 1. Depending on your layout, you may have to manually indicate that the content size of a visible cells has changed
    /// Use that step if you experience the `the behavior of the UICollectionViewFlowLayout is not defined` errors.

    collectionView.visibleCells.forEach { cell in
        guard let cell = cell as? CustomCell else {
            print("`viewWillTransition` failed. Wrong cell type")
            return
        }

        cell.recalculateFrame(newSize: size)

    }

    /// (Optional) Additional step 2. Recalculate layout if you've explicitly set the estimatedCellSize and you'll notice that layout changes aren't automatically visible after the #3

    (collectionView.collectionViewLayout as? CustomLayout)?.recalculateLayout(size: size)


    /// Step 3 (or 1 if none of the above is applicable)

    coordinator.animate(alongsideTransition: { context in
        self.collectionView.collectionViewLayout.invalidateLayout()
    }) { _ in
        // code to execute when the transition's finished.
    }

}

/// Example implementations of the `recalculateFrame` and `recalculateLayout` methods:

    /// Within the `CustomCell` class:
    func recalculateFrame(newSize: CGSize) {
        self.frame = CGRect(x: self.bounds.origin.x,
                            y: self.bounds.origin.y,
                            width: newSize.width - 14.0,
                            height: self.frame.size.height)
    }

    /// Within the `CustomLayout` class:
    func recalculateLayout(size: CGSize? = nil) {
        estimatedItemSize = CGSize(width: size.width - 14.0, height: 100)
    }

    /// IMPORTANT: Within the `CustomLayout` class.
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {

        guard let collectionView = collectionView else {
            return super.shouldInvalidateLayout(forBoundsChange: newBounds)
        }

        if collectionView.bounds.width != newBounds.width || collectionView.bounds.height != newBounds.height {
            return true
        } else {
            return false
        }
    }

Upvotes: 1

possen
possen

Reputation: 9276

This approach allows you do it without subclassing the layout, instead you add it to your, presumably already existing UICollectionViewController subclass, and avoids the potential of recursively calling viewWillLayoutSubviews it is a variation of the accepted solution, slightly simplified in that it does not use the transitionCoordinator. In Swift:

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

Upvotes: 4

bhr
bhr

Reputation: 2337

The solution for invalidating your layout when the bounds of the collection view changes is to override shouldInvalidateLayoutForBoundsChange: and return YES. It's also stated in the documentation: https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617781-shouldinvalidatelayoutforboundsc

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds 
{
     return YES;
}

This should cover rotation support as well. If it doesn't, implement viewWillTransitionToSize:withTransitionCoordinator:

- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator
{
    [super viewWillTransitionToSize:size
          withTransitionCoordinator:coordinator];

    [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context)
     {
         [self.collectionView.collectionViewLayout invalidateLayout];
     }
                                 completion:^(id<UIViewControllerTransitionCoordinatorContext> context)
     {
     }];
}

Upvotes: 53

eXtreme
eXtreme

Reputation: 876

  1. You should handle the case when the collection view size is changed. If you change the orientation or constraints, viewWillLayoutSubviews method will be triggered.

  2. You should invalidate the current collection view layout. After the layout is invalidated by using invalidateLayout method, the UICollectionViewDelegateFlowLayout methods will be triggered.

Here's the example code:

- (void)viewWillLayoutSubviews {
    [super viewWillLayoutSubviews];
    [mainCollectionView.collectionViewLayout invalidateLayout];
}

Upvotes: 12

Related Questions