From 6bdcac81d8b5fe7bf8824edc5c64372e99121ed3 Mon Sep 17 00:00:00 2001 From: Ryan Nystrom Date: Tue, 21 Feb 2017 15:27:26 -0800 Subject: [PATCH] Layout invalidation API Summary: Adding a new layout-invalidation API, telling the layout object to query and rebuild the layout for all items in the section controller. This works with `UICollectionViewFlowLayout` and should work with other custom layouts (including our own). Issue fixed: #360, #459 - [x] All tests pass. Demo project builds and runs. - [x] I added tests, an experiment, or detailed why my change isn't tested. - [x] I added an entry to the `CHANGELOG.md` for any breaking changes, enhancements, or bug fixes. Closes https://github.com/Instagram/IGListKit/pull/499 Reviewed By: jessesquires Differential Revision: D4590274 Pulled By: rnystrom fbshipit-source-id: f87235be4e6c024bf979b831a8938be68895e011 --- CHANGELOG.md | 2 ++ .../ExpandableSectionController.swift | 10 +++++-- .../IGListKitExamples/Views/LabelCell.swift | 2 +- Source/IGListAdapter.m | 21 ++++++++++++++ Source/IGListCollectionContext.h | 13 +++++++++ Source/IGListStackedSectionController.m | 4 +++ Tests/IGListAdapterE2ETests.m | 28 +++++++++++++++++++ Tests/Objects/IGTestDelegateController.h | 2 ++ Tests/Objects/IGTestDelegateController.m | 3 +- 9 files changed, 81 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a14c54e..cdb81618f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ This release closes the [3.0.0 milestone](https://github.com/Instagram/IGListKit - You can now manually move items (cells) within a section controller, ex: `[self.collectionContext moveInSectionController:self fromIndex:0 toIndex:1]`. [Ryan Nystrom](https://github.com/rnystrom) [(#418)](https://github.com/Instagram/IGListKit/pull/418) +- Invalidate the layout of a section controller and control the transition with `UIView` animation APIs. [Ryan Nystrom](https://github.com/rnystrom) [(#499)](https://github.com/Instagram/IGListKit/pull/499) + ### Fixes - Gracefully handle a `nil` section controller returned by an `IGListAdapterDataSource`. [Ryan Nystrom](https://github.com/rnystrom) [(tbd)](https://github.com/Instagram/IGListKit/pull/tbd) diff --git a/Examples/Examples-iOS/IGListKitExamples/SectionControllers/ExpandableSectionController.swift b/Examples/Examples-iOS/IGListKitExamples/SectionControllers/ExpandableSectionController.swift index 5ac451a0b..df47c1727 100644 --- a/Examples/Examples-iOS/IGListKitExamples/SectionControllers/ExpandableSectionController.swift +++ b/Examples/Examples-iOS/IGListKitExamples/SectionControllers/ExpandableSectionController.swift @@ -32,7 +32,6 @@ final class ExpandableSectionController: IGListSectionController, IGListSectionT func cellForItem(at index: Int) -> UICollectionViewCell { let cell = collectionContext!.dequeueReusableCell(of: LabelCell.self, for: self, at: index) as! LabelCell - cell.label.numberOfLines = expanded ? 0 : 1 cell.label.text = object return cell } @@ -43,7 +42,14 @@ final class ExpandableSectionController: IGListSectionController, IGListSectionT func didSelectItem(at index: Int) { expanded = !expanded - collectionContext?.reload(in: self, at: IndexSet(integer: 0)) + UIView.animate(withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 0.4, + initialSpringVelocity: 0.6, + options: [], + animations: { + self.collectionContext?.invalidateLayout(for: self) + }) } } diff --git a/Examples/Examples-iOS/IGListKitExamples/Views/LabelCell.swift b/Examples/Examples-iOS/IGListKitExamples/Views/LabelCell.swift index 25bb77dc7..fee2f730f 100644 --- a/Examples/Examples-iOS/IGListKitExamples/Views/LabelCell.swift +++ b/Examples/Examples-iOS/IGListKitExamples/Views/LabelCell.swift @@ -34,7 +34,7 @@ class LabelCell: UICollectionViewCell { let label: UILabel = { let label = UILabel() label.backgroundColor = .clear - label.numberOfLines = 1 + label.numberOfLines = 0 label.font = LabelCell.font return label }() diff --git a/Source/IGListAdapter.m b/Source/IGListAdapter.m index 7b9b0a087..495094162 100644 --- a/Source/IGListAdapter.m +++ b/Source/IGListAdapter.m @@ -1104,6 +1104,27 @@ - (void)scrollToSectionController:(IGListSectionController *) [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:scrollPosition animated:animated]; } +- (void)invalidateLayoutForSectionController:(IGListSectionController *)sectionController + completion:(void (^)(BOOL finished))completion{ + const NSInteger section = [self sectionForSectionController:sectionController]; + const NSInteger items = [_collectionView numberOfItemsInSection:section]; + + NSMutableArray *indexPaths = [NSMutableArray new]; + for (NSInteger item = 0; item < items; item++) { + [indexPaths addObject:[NSIndexPath indexPathForItem:item inSection:section]]; + } + + UICollectionViewLayout *layout = _collectionView.collectionViewLayout; + UICollectionViewLayoutInvalidationContext *context = [[[layout.class invalidationContextClass] alloc] init]; + [context invalidateItemsAtIndexPaths:indexPaths]; + + void (^block)() = ^{ + [layout invalidateLayoutWithContext:context]; + }; + + [_collectionView performBatchUpdates:block completion:completion]; +} + #pragma mark - UICollectionViewDelegateFlowLayout - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { diff --git a/Source/IGListCollectionContext.h b/Source/IGListCollectionContext.h index e7ced324b..80672d722 100644 --- a/Source/IGListCollectionContext.h +++ b/Source/IGListCollectionContext.h @@ -218,6 +218,19 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)reloadSectionController:(IGListSectionController *)sectionController; +/** + Invalidate the backing `UICollectionViewLayout` for all items in the section controller. + + @param sectionController The section controller that needs invalidating. + @param completion An optional completion block to execute when the updates are finished. + + @note This method can be wrapped in `UIView` animation APIs to control the duration or perform without animations. This + will end up calling `-[UICollectionView performBatchUpdates:completion:] internally, so invalidated changes may not be + reflected in the cells immediately. + */ +- (void)invalidateLayoutForSectionController:(IGListSectionController *)sectionController + completion:(nullable void (^)(BOOL finished))completion; + /** Batches and performs many cell-level updates in a single transaction. diff --git a/Source/IGListStackedSectionController.m b/Source/IGListStackedSectionController.m index 9561a9b2d..90e13843e 100644 --- a/Source/IGListStackedSectionController.m +++ b/Source/IGListStackedSectionController.m @@ -311,6 +311,10 @@ - (void)scrollToSectionController:(IGListSectionController *) animated:animated]; } +- (void)invalidateLayoutForSectionController:(IGListSectionController *)sectionController completion:(void (^)(BOOL))completion { + [self.collectionContext invalidateLayoutForSectionController:self completion:completion]; +} + #pragma mark - IGListDisplayDelegate - (void)listAdapter:(IGListAdapter *)listAdapter willDisplaySectionController:(IGListSectionController *)sectionController cell:(UICollectionViewCell *)cell atIndex:(NSInteger)index { diff --git a/Tests/IGListAdapterE2ETests.m b/Tests/IGListAdapterE2ETests.m index adee84f4b..d1dc51604 100644 --- a/Tests/IGListAdapterE2ETests.m +++ b/Tests/IGListAdapterE2ETests.m @@ -1337,4 +1337,32 @@ - (void)test_whenMovingItems_withNoBatchUpdate_thatCollectionViewWorks { XCTAssertEqualObjects(movedCell2.label.text, @"foo"); } +- (void)test_whenInvalidatingSectionController_withSizeChange_thatCellsAreSameInstance_thatCellsFrameChanged { + [self setupWithObjects:@[ + genTestObject(@1, @2), + ]]; + + NSIndexPath *path1 = [NSIndexPath indexPathForItem:0 inSection:0]; + NSIndexPath *path2 = [NSIndexPath indexPathForItem:1 inSection:0]; + IGTestCell *cell1 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:path1]; + IGTestCell *cell2 = (IGTestCell*)[self.collectionView cellForItemAtIndexPath:path2]; + + XCTAssertEqual(cell1.frame.size.height, 10); + XCTAssertEqual(cell2.frame.size.height, 10); + + IGTestDelegateController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.lastObject]; + section.height = 20.0; + + XCTestExpectation *expectation = genExpectation; + [section.collectionContext invalidateLayoutForSectionController:section completion:^(BOOL finished) { + XCTAssertEqual(cell1, [self.collectionView cellForItemAtIndexPath:path1]); + XCTAssertEqual(cell2, [self.collectionView cellForItemAtIndexPath:path2]); + XCTAssertEqual(cell1.frame.size.height, 20); + XCTAssertEqual(cell2.frame.size.height, 20); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:15 handler:nil]; +} + @end diff --git a/Tests/Objects/IGTestDelegateController.h b/Tests/Objects/IGTestDelegateController.h index 87acf6b1f..2da61bd6d 100644 --- a/Tests/Objects/IGTestDelegateController.h +++ b/Tests/Objects/IGTestDelegateController.h @@ -17,6 +17,8 @@ @property (nonatomic, strong, readonly) IGTestObject *item; +@property (nonatomic, assign) CGFloat height; + @property (nonatomic, copy) void (^itemUpdateBlock)(); @property (nonatomic, copy) void (^cellConfigureBlock)(IGTestDelegateController *); @property (nonatomic, assign, readonly) NSInteger updateCount; diff --git a/Tests/Objects/IGTestDelegateController.m b/Tests/Objects/IGTestDelegateController.m index 00dcdca7e..bedae4870 100644 --- a/Tests/Objects/IGTestDelegateController.m +++ b/Tests/Objects/IGTestDelegateController.m @@ -18,6 +18,7 @@ - (instancetype)init { if (self = [super init]) { _willDisplayCellIndexes = [NSCountedSet new]; _didEndDisplayCellIndexes = [NSCountedSet new]; + _height = 10.0; self.workingRangeDelegate = self; } return self; @@ -31,7 +32,7 @@ - (NSInteger)numberOfItems { } - (CGSize)sizeForItemAtIndex:(NSInteger)index { - return CGSizeMake(self.collectionContext.containerSize.width, 10); + return CGSizeMake(self.collectionContext.containerSize.width, self.height); } - (UICollectionViewCell *)cellForItemAtIndex:(NSInteger)index {