diff --git a/Source/IGListCollectionView.h b/Source/IGListCollectionView.h new file mode 100644 index 00000000..293ed048 --- /dev/null +++ b/Source/IGListCollectionView.h @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import + +@class IGListCollectionViewLayout; + +NS_ASSUME_NONNULL_BEGIN + +/** + This `UICollectionView` subclass allows for partial layout invalidation using `IGListCollectionViewLayout`. + + @note When updating a collection view (ex: calling `-insertSections`), `-invalidateLayoutWithContext` gets called on + the layout object. However, the invalidation context doesn't provide details on which index paths are being modified, + which typically forces a full layout re-calculation. `IGListCollectionView` gives `IGListCollectionViewLayout` the + missing information to re-calculate only the modified layout attributes. + */ +NS_SWIFT_NAME(ListCollectionView) +@interface IGListCollectionView : UICollectionView + +/** + Create a new view with an `IGListcollectionViewLayout` class or subclass. + + @param frame The frame to initialize with. + @param collectionViewLayout The layout to use with the collection view. + + @note You can initialize a new view with a base layout by simply calling `-[IGListCollectionView initWithFrame:]`. + */ +- (instancetype)initWithFrame:(CGRect)frame listCollectionViewLayout:(IGListCollectionViewLayout *)collectionViewLayout NS_DESIGNATED_INITIALIZER; + +/** + :nodoc: + */ +- (instancetype)initWithFrame:(CGRect)frame collectionViewLayout:(UICollectionViewLayout *)collectionViewLayout NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Source/IGListCollectionView.m b/Source/IGListCollectionView.m new file mode 100644 index 00000000..37fe667b --- /dev/null +++ b/Source/IGListCollectionView.m @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import "IGListCollectionView.h" + +#import "IGListCollectionViewLayout.h" + +@implementation IGListCollectionView + +#pragma mark - Init + +- (instancetype)initWithFrame:(CGRect)frame { + IGListCollectionViewLayout *layout = [[IGListCollectionViewLayout alloc] initWithStickyHeaders:NO topContentInset:0 stretchToEdge:YES]; + return [self initWithFrame:frame listCollectionViewLayout:layout]; +} + +- (instancetype)initWithFrame:(CGRect)frame listCollectionViewLayout:(IGListCollectionViewLayout *)collectionViewLayout { + return [super initWithFrame:frame collectionViewLayout:collectionViewLayout]; +} + +#pragma mark - IGListCollectionViewLayout + +- (IGListCollectionViewLayout *)listLayout { + if ([self.collectionViewLayout isKindOfClass:[IGListCollectionViewLayout class]]) { + return (IGListCollectionViewLayout *)self.collectionViewLayout; + } + + return nil; +} + +#pragma mark - Overides reloads + +- (void)reloadItemsAtIndexPaths:(NSArray *)indexPaths { + [self didModifyIndexPaths:indexPaths]; + [super reloadItemsAtIndexPaths:indexPaths]; +} + +- (void)reloadSections:(NSIndexSet *)sections { + [self didModifySections:sections]; + [super reloadSections:sections]; +} + +#pragma mark - Override deletes + +- (void)deleteItemsAtIndexPaths:(NSArray *)indexPaths { + [self didModifyIndexPaths:indexPaths]; + [super deleteItemsAtIndexPaths:indexPaths]; +} + +- (void)deleteSections:(NSIndexSet *)sections { + [self didModifySections:sections]; + [super deleteSections:sections]; +} + +#pragma mark - Override inserts + +- (void)insertItemsAtIndexPaths:(NSArray *)indexPaths { + [self didModifyIndexPaths:indexPaths]; + [super insertItemsAtIndexPaths:indexPaths]; +} + +- (void)insertSections:(NSIndexSet *)sections { + [self didModifySections:sections]; + [super insertSections:sections]; +} + +#pragma mark - Override moves + +- (void)moveItemAtIndexPath:(NSIndexPath *)indexPath toIndexPath:(NSIndexPath *)newIndexPath { + [self didModifyIndexPaths:@[indexPath, newIndexPath]]; + [super moveItemAtIndexPath:indexPath toIndexPath:newIndexPath]; +} + +- (void)moveSection:(NSInteger)section toSection:(NSInteger)newSection { + [self didModifySection:MIN(section, newSection)]; + [super moveSection:section toSection:newSection]; +} + +#pragma mark - Modify section + +- (void)didModifySections:(NSIndexSet *)sections { + if (sections.count == 0) { + return; + } + [self didModifySection:sections.firstIndex]; +} + +- (void)didModifySection:(NSUInteger)section { + [self.listLayout didModifySection:section]; +} + +#pragma mark - Modified index path + +- (void)didModifyIndexPaths:(NSArray *)indexPaths { + for (NSIndexPath *indexPath in indexPaths) { + [self didModifySection:indexPath.section]; + } +} + +@end diff --git a/Tests/IGListCollectionViewTests.m b/Tests/IGListCollectionViewTests.m new file mode 100644 index 00000000..05ffc618 --- /dev/null +++ b/Tests/IGListCollectionViewTests.m @@ -0,0 +1,189 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import + +#import + +#import "IGLayoutTestItem.h" +#import "IGLayoutTestSection.h" +#import "IGLayoutTestDataSource.h" +#import "IGListTestHelpers.h" + +@interface IGListCollectionViewTests : XCTestCase + +@property (nonatomic, strong) IGListCollectionView *collectionView; +@property (nonatomic, strong) IGLayoutTestDataSource *dataSource; + +@end + +@implementation IGListCollectionViewTests + +- (void)setUp { + [super setUp]; + self.dataSource = [IGLayoutTestDataSource new]; + self.collectionView = [[IGListCollectionView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + self.collectionView.dataSource = self.dataSource; + self.collectionView.delegate = self.dataSource; + [self.dataSource configCollectionView:self.collectionView]; +} + +#pragma mark - Reload All + +- (void)test_whenReloadData_thatEntireLayoutUpdates { + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]) + ]; + [self.collectionView reloadData]; + [self.collectionView layoutIfNeeded]; + + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(20, 20))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]) + ]; + [self.collectionView reloadData]; + [self.collectionView layoutIfNeeded]; + + IGAssertEqualFrame([self cellForSection:0 item:0].frame, 0, 0, 20, 20); + IGAssertEqualFrame([self cellForSection:1 item:0].frame, 20, 0, 10, 10); +} + +#pragma mark - Insert/Delete/Reload/Move + +- (void)test_whenInsertingSection_thatLayoutPartiallyUpdates { + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]) + ]; + [self.collectionView reloadData]; + [self.collectionView layoutIfNeeded]; + + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(20, 20))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]) + ]; + [self.collectionView insertSections:[NSIndexSet indexSetWithIndex:1]]; + + // check that section 0 wasn't updated + IGAssertEqualFrame([self cellForSection:0 item:0].frame, 0, 0, 10, 10); + // check that section 1 was updated + IGAssertEqualFrame([self cellForSection:1 item:0].frame, 10, 0, 10, 10); +} + +- (void)test_whenDeletingSection_thatLayoutPartiallyUpdates { + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]) + ]; + [self.collectionView reloadData]; + [self.collectionView layoutIfNeeded]; + + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(20, 20))]), + ]; + [self.collectionView deleteSections:[NSIndexSet indexSetWithIndex:1]]; + + // check that section 0 wasn't updated + IGAssertEqualFrame([self cellForSection:0 item:0].frame, 0, 0, 10, 10); +} + +- (void)test_whenReloadingSection_thatLayoutPartiallyUpdates { + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]) + ]; + [self.collectionView reloadData]; + [self.collectionView layoutIfNeeded]; + + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(20, 20))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(20, 20))]), + ]; + [self.collectionView reloadSections:[NSIndexSet indexSetWithIndex:1]]; + + // check that section 0 wasn't updated + IGAssertEqualFrame([self cellForSection:0 item:0].frame, 0, 0, 10, 10); + // check that section 1 was updated + IGAssertEqualFrame([self cellForSection:1 item:0].frame, 10, 0, 20, 20); +} + +- (void)test_whenMoveSection_thatLayoutPartiallyUpdates { + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(10, 10))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(20, 20))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(30, 30))]) + ]; + [self.collectionView reloadData]; + [self.collectionView layoutIfNeeded]; + + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(40, 40))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(30, 30))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(20, 20))]), + ]; + [self.collectionView moveSection:1 toSection:2]; + + // check that section 0 wasn't updated + IGAssertEqualFrame([self cellForSection:0 item:0].frame, 0, 0, 10, 10); + // check that section 1 was updated + IGAssertEqualFrame([self cellForSection:1 item:0].frame, 10, 0, 30, 30); + // check that section 2 was updated + IGAssertEqualFrame([self cellForSection:2 item:0].frame, 40, 0, 20, 20); +} + +#pragma mark - Batch + +- (void)test_whenInsertDeleteMoveSection_thatLayoutPartiallyUpdates { + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(1, 1))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(2, 2))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(3, 3))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(4, 4))]), + ]; + [self.collectionView reloadData]; + [self.collectionView layoutIfNeeded]; + + self.dataSource.sections = @[ + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(0, 0))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(4, 4))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(3, 3))]), + genLayoutTestSection(@[genLayoutTestItem(CGSizeMake(5, 5))]), + ]; + + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + + [self.collectionView performBatchUpdates:^{ + [self.collectionView deleteSections:[NSIndexSet indexSetWithIndex:1]]; // deleted (2, 2) + [self.collectionView moveSection:3 toSection:1]; // move (4, 4) + [self.collectionView insertSections:[NSIndexSet indexSetWithIndex:3]]; // inserted (5, 5) + } completion:^(BOOL finished) { + [self.collectionView layoutIfNeeded]; + [expectation fulfill]; + + // check that section 0 wasn't updated + IGAssertEqualFrame([self cellForSection:0 item:0].frame, 0, 0, 1, 1); + // check that section 1 was updated + IGAssertEqualFrame([self cellForSection:1 item:0].frame, 1, 0, 4, 4); + // check that section 2 was updated + IGAssertEqualFrame([self cellForSection:2 item:0].frame, 5, 0, 3, 3); + // check that section 3 was updated + IGAssertEqualFrame([self cellForSection:3 item:0].frame, 8, 0, 5, 5); + }]; + + [self waitForExpectationsWithTimeout:30 handler:^(NSError * _Nullable error) { + XCTAssertNil(error); + }]; +} + +#pragma mark - Helpers + +- (UICollectionViewCell *)cellForSection:(NSInteger)section item:(NSInteger)item { + return [self.collectionView cellForItemAtIndexPath:genIndexPath(section, item)]; +} + +@end diff --git a/Tests/Objects/IGLayoutTestItem.h b/Tests/Objects/IGLayoutTestItem.h index a98eea45..7202a8b7 100644 --- a/Tests/Objects/IGLayoutTestItem.h +++ b/Tests/Objects/IGLayoutTestItem.h @@ -9,6 +9,8 @@ #import +#define genLayoutTestItem(s) [[IGLayoutTestItem alloc] initWithSize:s] + @interface IGLayoutTestItem : NSObject @property (nonatomic, assign, readonly) CGSize size; diff --git a/Tests/Objects/IGLayoutTestSection.h b/Tests/Objects/IGLayoutTestSection.h index 0718117f..2ef62867 100644 --- a/Tests/Objects/IGLayoutTestSection.h +++ b/Tests/Objects/IGLayoutTestSection.h @@ -11,6 +11,8 @@ @class IGLayoutTestItem; +#define genLayoutTestSection(i) [[IGLayoutTestSection alloc] initWithItems:i] + @interface IGLayoutTestSection : NSObject @property (nonatomic, assign, readonly) UIEdgeInsets insets; @@ -20,11 +22,13 @@ @property (nonatomic, assign, readonly) CGFloat footerHeight; @property (nonatomic, strong, readonly) NSArray *items; +- (instancetype)initWithItems:(NSArray *)items; + - (instancetype)initWithInsets:(UIEdgeInsets)insets lineSpacing:(CGFloat)lineSpacing interitemSpacing:(CGFloat)interitemSpacing headerHeight:(CGFloat)headerHeight footerHeight:(CGFloat)footerHeight - items:(NSArray *)items; + items:(NSArray *)items NS_DESIGNATED_INITIALIZER; @end diff --git a/Tests/Objects/IGLayoutTestSection.m b/Tests/Objects/IGLayoutTestSection.m index 2f5a4e22..8d3c24d8 100644 --- a/Tests/Objects/IGLayoutTestSection.m +++ b/Tests/Objects/IGLayoutTestSection.m @@ -11,6 +11,15 @@ @implementation IGLayoutTestSection +- (instancetype)initWithItems:(NSArray *)items { + return [self initWithInsets:UIEdgeInsetsZero + lineSpacing:0 + interitemSpacing:0 + headerHeight:0 + footerHeight:0 + items:items]; +} + - (instancetype)initWithInsets:(UIEdgeInsets)insets lineSpacing:(CGFloat)lineSpacing interitemSpacing:(CGFloat)interitemSpacing