From 7a23ed521d94b77ec0fdf8090d152a79bbdd80e3 Mon Sep 17 00:00:00 2001 From: Maxime Ollivier Date: Fri, 15 Dec 2017 09:19:20 -0800 Subject: [PATCH] update IGListCollectionViewLayout to allow for partial layout invalidation Summary: * Currently, we invalidate the entire layout whenever we make any updates, like inserting new rows at the bottom. * This is one of the most common causes of frame drop on feed, so let's allow partial invalidation based on the minimum modified section. * For example, if we delete section 10, move section 4, and insert section 12, we would re-calculate the layout starting at section 4. * This gets us the majority of the performance gains and it's relatively simple. In the future, we can make further optimizations, like 1) index path level invalidation and 2) finding the smallest modified index path whose properties (ex: size) have actually changed. Reviewed By: rnystrom Differential Revision: D6510140 fbshipit-source-id: 6ff1766b400c5aa82abc29ae76ab96660c3bb106 --- Source/IGListCollectionViewLayout.mm | 59 ++++++++++++++++++------- Tests/IGListCollectionViewLayoutTests.m | 36 +++++++++++++++ 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/Source/IGListCollectionViewLayout.mm b/Source/IGListCollectionViewLayout.mm index 20d321fb..324635b2 100644 --- a/Source/IGListCollectionViewLayout.mm +++ b/Source/IGListCollectionViewLayout.mm @@ -87,6 +87,15 @@ struct IGListSectionEntry { // An array of frames for each cell in the section. std::vector itemBounds; + // last item distance in scroll direction, used for partial invalidation + CGFloat lastItemCoordInScrollDirection; + + // last item distance in fixed direction, used for partial invalidation + CGFloat lastItemCoordInFixedDirection; + + // last next row distance in scroll direction, used for partial invalidation + CGFloat lastNextRowCoordInScrollDirection; + // Returns YES when the section has visible content (header and/or items). BOOL isValid() { return !CGSizeEqualToSize(bounds.size, CGSizeZero); @@ -133,7 +142,9 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut @implementation IGListCollectionViewLayout { std::vector _sectionData; NSMutableDictionary *_attributesCache; - BOOL _cachedLayoutInvalid; + + // invalidate starting at this section + NSInteger _minimumInvalidatedSection; /** The workflow for getting sticky headers working: @@ -171,7 +182,7 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut UICollectionElementKindSectionHeader: [NSMutableDictionary new], UICollectionElementKindSectionFooter: [NSMutableDictionary new], }]; - _cachedLayoutInvalid = YES; + _minimumInvalidatedSection = NSNotFound; } return self; } @@ -337,7 +348,7 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut || [context invalidateEverything] || [context invalidateDataSourceCounts] || context.ig_invalidateAllAttributes) { - _cachedLayoutInvalid = YES; + _minimumInvalidatedSection = 0; // invalidates all } if (context.ig_invalidateSupplementaryAttributes) { @@ -376,9 +387,7 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut } - (void)prepareLayout { - if (_cachedLayoutInvalid) { - [self cacheLayout]; - } + [self calculateLayoutIfNeeded]; } #pragma mark - Public API @@ -397,8 +406,10 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut #pragma mark - Private API -- (void)cacheLayout { - _cachedLayoutInvalid = NO; +- (void)calculateLayoutIfNeeded { + if (_minimumInvalidatedSection == NSNotFound) { + return; + } // purge attribute caches so they are rebuilt [_attributesCache removeAllObjects]; @@ -412,7 +423,7 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut const UIEdgeInsets contentInset = collectionView.contentInset; const CGRect contentInsetAdjustedCollectionViewBounds = UIEdgeInsetsInsetRect(collectionView.bounds, contentInset); - auto sectionData = std::vector(sectionCount); + _sectionData.resize(sectionCount); CGFloat itemCoordInScrollDirection = 0.0; CGFloat itemCoordInFixedDirection = 0.0; @@ -421,9 +432,18 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut // union item frames and optionally the header to find a bounding box of the entire section CGRect rollingSectionBounds; - for (NSInteger section = 0; section < sectionCount; section++) { + // populate last valid section information + const NSInteger lastValidSection = _minimumInvalidatedSection - 1; + if (lastValidSection >= 0 && lastValidSection < sectionCount) { + itemCoordInScrollDirection = _sectionData[lastValidSection].lastItemCoordInScrollDirection; + itemCoordInFixedDirection = _sectionData[lastValidSection].lastItemCoordInFixedDirection; + nextRowCoordInScrollDirection = _sectionData[lastValidSection].lastNextRowCoordInScrollDirection; + rollingSectionBounds = _sectionData[lastValidSection].bounds; + } + + for (NSInteger section = _minimumInvalidatedSection; section < sectionCount; section++) { const NSInteger itemCount = [dataSource collectionView:collectionView numberOfItemsInSection:section]; - sectionData[section].itemBounds = std::vector(itemCount); + _sectionData[section].itemBounds = std::vector(itemCount); const CGSize headerSize = [delegate collectionView:collectionView layout:self referenceSizeForHeaderInSection:section]; const CGSize footerSize = [delegate collectionView:collectionView layout:self referenceSizeForFooterInSection:section]; @@ -498,7 +518,7 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut itemLengthInFixedDirection); const CGRect frame = IGListRectIntegralScaled(rawFrame); - sectionData[section].itemBounds[item] = frame; + _sectionData[section].itemBounds[item] = frame; // track the max size of the row to find the coord of the next row, adjust for leading inset while iterating items nextRowCoordInScrollDirection = MAX(CGRectGetMaxInDirection(frame, self.scrollDirection) - UIEdgeInsetsLeadingInsetInDirection(insets, self.scrollDirection), nextRowCoordInScrollDirection); @@ -524,7 +544,7 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut headerSize.width, paddedLengthInFixedDirection); - sectionData[section].headerBounds = headerBounds; + _sectionData[section].headerBounds = headerBounds; const CGRect footerBounds = (self.scrollDirection == UICollectionViewScrollDirectionVertical) ? CGRectMake(insets.left, @@ -536,7 +556,7 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut footerSize.width, paddedLengthInFixedDirection); - sectionData[section].footerBounds = footerBounds; + _sectionData[section].footerBounds = footerBounds; // union the header before setting the bounds of the section // only do this when the header has a size, otherwise the union stretches to box empty space @@ -547,17 +567,22 @@ static void adjustZIndexForAttributes(UICollectionViewLayoutAttributes *attribut rollingSectionBounds = CGRectUnion(rollingSectionBounds, footerBounds); } - sectionData[section].bounds = rollingSectionBounds; - sectionData[section].insets = insets; + _sectionData[section].bounds = rollingSectionBounds; + _sectionData[section].insets = insets; // bump the coord for the next section with the right insets itemCoordInFixedDirection += UIEdgeInsetsTrailingInsetInDirection(insets, fixedDirection); // find the farthest point in the section and add the trailing inset to find the next row's coord nextRowCoordInScrollDirection = MAX(nextRowCoordInScrollDirection, CGRectGetMaxInDirection(rollingSectionBounds, self.scrollDirection) + UIEdgeInsetsTrailingInsetInDirection(insets, self.scrollDirection)); + + // keep track of coordinates for partial invalidation + _sectionData[section].lastItemCoordInScrollDirection = itemCoordInScrollDirection; + _sectionData[section].lastItemCoordInFixedDirection = itemCoordInFixedDirection; + _sectionData[section].lastNextRowCoordInScrollDirection = nextRowCoordInScrollDirection; } - _sectionData = sectionData; + _minimumInvalidatedSection = NSNotFound; } - (NSRange)rangeOfSectionsInRect:(CGRect)rect { diff --git a/Tests/IGListCollectionViewLayoutTests.m b/Tests/IGListCollectionViewLayoutTests.m index 67ed1dde..035d7cd0 100644 --- a/Tests/IGListCollectionViewLayoutTests.m +++ b/Tests/IGListCollectionViewLayoutTests.m @@ -1099,4 +1099,40 @@ static const CGRect kTestFrame = (CGRect){{0, 0}, {100, 100}}; XCTAssertNil([self.layout layoutAttributesForItemAtIndexPath:genIndexPath(0, 4)]); } +- (void)test_whenUpdatingSizes_thatLayoutUpdates { + [self setUpWithStickyHeaders:NO topInset:0]; + + [self prepareWithData:@[ + [[IGLayoutTestSection alloc] initWithInsets:UIEdgeInsetsZero + lineSpacing:0 + interitemSpacing:0 + headerHeight:0 + footerHeight:0 + items:@[ + [[IGLayoutTestItem alloc] initWithSize:CGSizeMake(10, 10)], + [[IGLayoutTestItem alloc] initWithSize:CGSizeMake(10, 10)], + ]], + ]]; + + IGAssertEqualFrame([self cellForSection:0 item:0].frame, 0, 0, 10, 10); + IGAssertEqualFrame([self cellForSection:0 item:1].frame, 10, 0, 10, 10); + + [self prepareWithData:@[ + [[IGLayoutTestSection alloc] initWithInsets:UIEdgeInsetsZero + lineSpacing:0 + interitemSpacing:0 + headerHeight:0 + footerHeight:0 + items:@[ + [[IGLayoutTestItem alloc] initWithSize:CGSizeMake(20, 20)], + [[IGLayoutTestItem alloc] initWithSize:CGSizeMake(20, 20)], + [[IGLayoutTestItem alloc] initWithSize:CGSizeMake(20, 20)], + ]], + ]]; + + IGAssertEqualFrame([self cellForSection:0 item:0].frame, 0, 0, 20, 20); + IGAssertEqualFrame([self cellForSection:0 item:1].frame, 20, 0, 20, 20); + IGAssertEqualFrame([self cellForSection:0 item:2].frame, 40, 0, 20, 20); +} + @end