diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b2f59fb..d8004864d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,20 @@ The changelog for `IGListKit`. Also see the [releases](https://github.com/instag 3.2.0 (upcoming release) ----- +### Enhancements + +- Added `-[IGListSectionController didHighlightItemAtIndex:]` and `-[IGListSectionController didUnhighlightItemAtIndex:]` APIs to support `UICollectionView` cell highlighting. [Kevin Delannoy](https://github.com/delannoyk) [(#933)](https://github.com/Instagram/IGListKit/pull/933) + ### Fixes - Weakly reference the `UICollectionView` in coalescence so that it can be released if the rest of system is destroyed. [Ryan Nystrom](https://github.com/rnystrom) [(#tbd)](https://github.com/Instagram/IGListKit/pull/tbd) - Fix bug with `-[IGListAdapter scrollToObject:supplementaryKinds:scrollDirection:scrollPosition:animated:]` where the content inset of the collection view was incorrectly being applied to the final offset. [Ryan Nystrom](https://github.com/rnystrom) [(#tbd)](https://github.com/Instagram/IGListKit/pull/tbd) +- Avoid crash when invalidating the layout while inside `-[UICollectionView performBatchUpdates:completion:]. [Ryan Nystrom](https://github.com/rnystrom) [(#tbd)](https://github.com/Instagram/IGListKit/pull/tbd) + +- Duplicate view models in `IGListBindingSectionController` gets filtered out. [Weyert de Boer](https://github.com/weyert) [(#916)](https://github.com/Instagram/IGListKit/pull/916) + 3.1.1 ----- diff --git a/IGListKit.xcodeproj/project.pbxproj b/IGListKit.xcodeproj/project.pbxproj index bf0c5faae..ce1fd5946 100644 --- a/IGListKit.xcodeproj/project.pbxproj +++ b/IGListKit.xcodeproj/project.pbxproj @@ -258,6 +258,9 @@ 29DA5CA81EA7D37000113926 /* IGListTestCase.m in Sources */ = {isa = PBXBuildFile; fileRef = 29DA5CA61EA7D37000113926 /* IGListTestCase.m */; }; 29EA6C491DB43A8000957A88 /* IGTestNibCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 294369B01DB1B7AE0025F6E7 /* IGTestNibCell.xib */; }; 64D8007E592D0292BE4FC21D /* Pods_IGListKitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68CC152B514785B3113DDD4A /* Pods_IGListKitTests.framework */; }; + 7BC0C61B1F5C401F00A06ADD /* IGListArrayUtilsInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 7BC0C61A1F5C401F00A06ADD /* IGListArrayUtilsInternal.h */; }; + 7BC0C61C1F5C402600A06ADD /* IGListArrayUtilsInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 7BC0C61A1F5C401F00A06ADD /* IGListArrayUtilsInternal.h */; }; + 7BC0C61D1F5C402600A06ADD /* IGListArrayUtilsInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 7BC0C61A1F5C401F00A06ADD /* IGListArrayUtilsInternal.h */; }; 821BC4C01DB8C9D500172ED0 /* IGListSingleStoryboardItemControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 821BC4BE1DB8C95300172ED0 /* IGListSingleStoryboardItemControllerTests.m */; }; 821BC4C41DB8CEF800172ED0 /* IGTestStoryboard.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 821BC4C21DB8CAE900172ED0 /* IGTestStoryboard.storyboard */; }; 821BC4CB1DB8D60100172ED0 /* IGTestStoryboardViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 821BC4C81DB8D5B200172ED0 /* IGTestStoryboardViewController.m */; }; @@ -498,6 +501,7 @@ 4883B4E449DE18EC9C37773B /* Pods-IGListKitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-IGListKitTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-IGListKitTests/Pods-IGListKitTests.debug.xcconfig"; sourceTree = ""; }; 68CC152B514785B3113DDD4A /* Pods_IGListKitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_IGListKitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6DBA851B0E1DFDC2E7BDA472 /* Pods-IGListKitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-IGListKitTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-IGListKitTests/Pods-IGListKitTests.release.xcconfig"; sourceTree = ""; }; + 7BC0C61A1F5C401F00A06ADD /* IGListArrayUtilsInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IGListArrayUtilsInternal.h; sourceTree = ""; }; 821BC4BE1DB8C95300172ED0 /* IGListSingleStoryboardItemControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListSingleStoryboardItemControllerTests.m; sourceTree = ""; }; 821BC4C21DB8CAE900172ED0 /* IGTestStoryboard.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = IGTestStoryboard.storyboard; sourceTree = ""; }; 821BC4C71DB8D5B200172ED0 /* IGTestStoryboardViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IGTestStoryboardViewController.h; sourceTree = ""; }; @@ -700,6 +704,7 @@ 0B3B92931E08D7F5008390ED /* IGListIndexSetResultInternal.h */, 0B3B92941E08D7F5008390ED /* IGListMoveIndexInternal.h */, 0B3B92951E08D7F5008390ED /* IGListMoveIndexPathInternal.h */, + 7BC0C61A1F5C401F00A06ADD /* IGListArrayUtilsInternal.h */, ); path = Internal; sourceTree = ""; @@ -932,6 +937,7 @@ 294652BA1EA927750063BDD9 /* IGListDebuggingUtilities.h in Headers */, 0B3B93351E08D7F5008390ED /* IGListDisplayHandler.h in Headers */, 0B3B92D31E08D7F5008390ED /* IGListIndexPathResult.h in Headers */, + 7BC0C61C1F5C402600A06ADD /* IGListArrayUtilsInternal.h in Headers */, 0B3B93251E08D7F5008390ED /* IGListSupplementaryViewSource.h in Headers */, 0B3B92F31E08D7F5008390ED /* NSString+IGListDiffable.h in Headers */, 0B3B932D1E08D7F5008390ED /* IGListAdapterInternal.h in Headers */, @@ -1014,6 +1020,7 @@ 0B3B92D01E08D7F5008390ED /* IGListExperiments.h in Headers */, 296AC95C1EA518D3005137E2 /* IGListReloadIndexPath.h in Headers */, 0B3B93321E08D7F5008390ED /* IGListAdapterUpdaterInternal.h in Headers */, + 7BC0C61B1F5C401F00A06ADD /* IGListArrayUtilsInternal.h in Headers */, DA5F484B1E8E9D7000DAE6DA /* IGListAdapter+UICollectionView.h in Headers */, 0B3B93381E08D7F5008390ED /* IGListSectionControllerInternal.h in Headers */, 2926586F1E75E0830041B56D /* IGListBindingSectionControllerSelectionDelegate.h in Headers */, @@ -1058,6 +1065,7 @@ 0B3B934B1E08D82E008390ED /* IGListMoveIndexPathInternal.h in Headers */, 0B3B93521E08D839008390ED /* IGListIndexSetResult.h in Headers */, 0B3B934C1E08D839008390ED /* IGListAssert.h in Headers */, + 7BC0C61D1F5C402600A06ADD /* IGListArrayUtilsInternal.h in Headers */, 292658551E7498220041B56D /* IGListKit.h in Headers */, 0B3B93561E08D839008390ED /* IGListMoveIndexPath.h in Headers */, 0B3B93511E08D839008390ED /* IGListIndexPathResult.h in Headers */, diff --git a/Source/Common/Internal/IGListArrayUtilsInternal.h b/Source/Common/Internal/IGListArrayUtilsInternal.h new file mode 100644 index 000000000..e3aed12ac --- /dev/null +++ b/Source/Common/Internal/IGListArrayUtilsInternal.h @@ -0,0 +1,33 @@ +/** + * 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. + */ + +#ifndef IGListArrayUtilsInternal_h +#define IGListArrayUtilsInternal_h + +static NSArray *objectsWithDuplicateIdentifiersRemoved(NSArray> *objects) { + if (objects == nil) { + return nil; + } + + NSMutableSet *identifiers = [NSMutableSet new]; + NSMutableArray *uniqueObjects = [NSMutableArray new]; + for (id object in objects) { + id diffIdentifier = [object diffIdentifier]; + if (diffIdentifier != nil + && ![identifiers containsObject:diffIdentifier]) { + [identifiers addObject:diffIdentifier]; + [uniqueObjects addObject:object]; + } else { + IGLKLog(@"WARNING: Object %@ already appeared in objects array", object); + } + } + return uniqueObjects; +} + +#endif /* IGListArrayUtilsInternal_h */ diff --git a/Source/IGListAdapter.m b/Source/IGListAdapter.m index 6f0be42ea..85077f135 100644 --- a/Source/IGListAdapter.m +++ b/Source/IGListAdapter.m @@ -18,6 +18,8 @@ @implementation IGListAdapter { NSMapTable *_viewSectionControllerMap; + // An array of blocks to execute once batch updates are finished + NSMutableArray *_queuedCompletionBlocks; } - (void)dealloc { @@ -317,6 +319,8 @@ - (void)performUpdatesAnimated:(BOOL)animated completion:(IGListUpdaterCompletio NSArray *fromObjects = self.sectionMap.objects; NSArray *newObjects = [dataSource objectsForListAdapter:self]; + [self enterBatchUpdates]; + __weak __typeof__(self) weakSelf = self; [self.updater performUpdateWithCollectionView:collectionView fromObjects:fromObjects @@ -335,6 +339,8 @@ - (void)performUpdatesAnimated:(BOOL)animated completion:(IGListUpdaterCompletio if (completion) { completion(finished); } + + [weakSelf exitBatchUpdates]; }]; } @@ -702,6 +708,26 @@ - (void)removeMapForView:(UICollectionReusableView *)view { [_viewSectionControllerMap removeObjectForKey:view]; } +- (void)deferBlockBetweenBatchUpdates:(void (^)())block { + IGAssertMainThread(); + if (_queuedCompletionBlocks == nil) { + block(); + } else { + [_queuedCompletionBlocks addObject:block]; + } +} + +- (void)enterBatchUpdates { + _queuedCompletionBlocks = [NSMutableArray new]; +} + +- (void)exitBatchUpdates { + for (void (^block)() in _queuedCompletionBlocks) { + block(); + } + _queuedCompletionBlocks = nil; +} + #pragma mark - UIScrollViewDelegate - (void)scrollViewDidScroll:(UIScrollView *)scrollView { @@ -964,6 +990,8 @@ - (void)performBatchAnimated:(BOOL)animated updates:(void (^)(id> *objects) { - if (objects == nil) { - return nil; - } - - NSMutableSet *identifiers = [NSMutableSet new]; - NSMutableArray *uniqueObjects = [NSMutableArray new]; - for (id object in objects) { - id diffIdentifier = [object diffIdentifier]; - if (diffIdentifier != nil - && ![identifiers containsObject:diffIdentifier]) { - [identifiers addObject:diffIdentifier]; - [uniqueObjects addObject:object]; - } else { - IGLKLog(@"WARNING: Object %@ already appeared in objects array", object); - } - } - return uniqueObjects; -} - - (void)performBatchUpdatesWithCollectionView:(UICollectionView *)collectionView { IGAssertMainThread(); IGAssert(self.state == IGListBatchUpdateStateIdle, @"Should not call batch updates when state isn't idle"); @@ -151,7 +131,7 @@ - (void)performBatchUpdatesWithCollectionView:(UICollectionView *)collectionView for (IGListItemUpdateBlock itemUpdateBlock in batchUpdates.itemUpdateBlocks) { itemUpdateBlock(); } - + // add any completion blocks from item updates. added after item blocks are executed in order to capture any // re-entrant updates [completionBlocks addObjectsFromArray:batchUpdates.itemCompletionBlocks]; @@ -361,7 +341,7 @@ - (void)cleanStateBeforeUpdates { // remove indexpath/item changes self.objectTransitionBlock = nil; - + // removes all object completion blocks. done before updates to start collecting completion blocks for coalesced // or re-entrant object updates [self.completionBlocks removeAllObjects]; @@ -471,7 +451,7 @@ - (void)performUpdateWithCollectionView:(UICollectionView *)collectionView IGAssertMainThread(); IGParameterAssert(collectionView != nil); IGParameterAssert(itemUpdates != nil); - + IGListBatchUpdates *batchUpdates = self.batchUpdates; if (completion != nil) { [batchUpdates.itemCompletionBlocks addObject:completion]; @@ -483,11 +463,11 @@ - (void)performUpdateWithCollectionView:(UICollectionView *)collectionView itemUpdates(); } else { [batchUpdates.itemUpdateBlocks addObject:itemUpdates]; - + // disabled animations will always take priority // reset to YES in -cleanupState self.queuedUpdateIsAnimated = self.queuedUpdateIsAnimated && animated; - + [self queueUpdateWithCollectionView:collectionView]; } } @@ -569,3 +549,4 @@ - (void)reloadCollectionView:(UICollectionView *)collectionView sections:(NSInde } @end + diff --git a/Source/IGListBindingSectionController.m b/Source/IGListBindingSectionController.m index 549cf486d..b7e732961 100644 --- a/Source/IGListBindingSectionController.m +++ b/Source/IGListBindingSectionController.m @@ -13,6 +13,9 @@ #import #import #import +#import + +#import "IGListArrayUtilsInternal.h" typedef NS_ENUM(NSInteger, IGListDiffingSectionState) { IGListDiffingSectionStateIdle = 0, @@ -58,7 +61,8 @@ - (void)updateAnimated:(BOOL)animated completion:(void (^)(BOOL))completion { id object = self.object; IGAssert(object != nil, @"Expected IGListBindingSectionController object to be non-nil before updating."); - self.viewModels = [self.dataSource sectionController:self viewModelsForObject:object]; + NSArray *newViewModels = [self.dataSource sectionController:self viewModelsForObject:object]; + self.viewModels = objectsWithDuplicateIdentifiersRemoved(newViewModels); result = IGListDiff(oldViewModels, self.viewModels, IGListDiffEquality); [result.updates enumerateIndexesUsingBlock:^(NSUInteger oldUpdatedIndex, BOOL *stop) { @@ -130,4 +134,18 @@ - (void)didDeselectItemAtIndex:(NSInteger)index { } } +- (void)didHighlightItemAtIndex:(NSInteger)index { + id selectionDelegate = self.selectionDelegate; + if ([selectionDelegate respondsToSelector:@selector(sectionController:didHighlightItemAtIndex:viewModel:)]) { + [selectionDelegate sectionController:self didHighlightItemAtIndex:index viewModel:self.viewModels[index]]; + } +} + +- (void)didUnhighlightItemAtIndex:(NSInteger)index { + id selectionDelegate = self.selectionDelegate; + if ([selectionDelegate respondsToSelector:@selector(sectionController:didUnhighlightItemAtIndex:viewModel:)]) { + [selectionDelegate sectionController:self didUnhighlightItemAtIndex:index viewModel:self.viewModels[index]]; + } +} + @end diff --git a/Source/IGListBindingSectionControllerSelectionDelegate.h b/Source/IGListBindingSectionControllerSelectionDelegate.h index 936e221a5..ae5992e54 100644 --- a/Source/IGListBindingSectionControllerSelectionDelegate.h +++ b/Source/IGListBindingSectionControllerSelectionDelegate.h @@ -44,6 +44,30 @@ NS_SWIFT_NAME(ListBindingSectionControllerSelectionDelegate) didDeselectItemAtIndex:(NSInteger)index viewModel:(id)viewModel; +/** + Tells the delegate that a cell at a given index was highlighted. + + @param sectionController The section controller the highlight occurred in. + @param index The index of the highlighted cell. + @param viewModel The view model that was bound to the cell. + */ +@optional +- (void)sectionController:(IGListBindingSectionController *)sectionController + didHighlightItemAtIndex:(NSInteger)index + viewModel:(id)viewModel; + +/** + Tells the delegate that a cell at a given index was unhighlighted. + + @param sectionController The section controller the unhighlight occurred in. + @param index The index of the unhighlighted cell. + @param viewModel The view model that was bound to the cell. + */ +@optional +- (void)sectionController:(IGListBindingSectionController *)sectionController +didUnhighlightItemAtIndex:(NSInteger)index + viewModel:(id)viewModel; + @end NS_ASSUME_NONNULL_END diff --git a/Source/IGListSectionController.h b/Source/IGListSectionController.h index 8d02fbf68..1846a9b92 100644 --- a/Source/IGListSectionController.h +++ b/Source/IGListSectionController.h @@ -90,6 +90,24 @@ NS_SWIFT_NAME(ListSectionController) */ - (void)didDeselectItemAtIndex:(NSInteger)index; +/** + Tells the section controller that the cell at the specified index path was highlighted. + + @param index The index of the highlighted cell. + + @note The default implementation does nothing. **Calling super is not required.** + */ +- (void)didHighlightItemAtIndex:(NSInteger)index; + +/** + Tells the section controller that the cell at the specified index path was unhighlighted. + + @param index The index of the unhighlighted cell. + + @note The default implementation does nothing. **Calling super is not required.** + */ +- (void)didUnhighlightItemAtIndex:(NSInteger)index; + /** The view controller housing the adapter that created this section controller. diff --git a/Source/IGListSectionController.m b/Source/IGListSectionController.m index ccef02eae..242a73aea 100644 --- a/Source/IGListSectionController.m +++ b/Source/IGListSectionController.m @@ -86,4 +86,8 @@ - (void)didSelectItemAtIndex:(NSInteger)index {} - (void)didDeselectItemAtIndex:(NSInteger)index {} +- (void)didHighlightItemAtIndex:(NSInteger)index {} + +- (void)didUnhighlightItemAtIndex:(NSInteger)index {} + @end diff --git a/Source/IGListStackedSectionController.m b/Source/IGListStackedSectionController.m index 9e7d26277..0ac91c4e7 100644 --- a/Source/IGListStackedSectionController.m +++ b/Source/IGListStackedSectionController.m @@ -166,6 +166,18 @@ - (void)didDeselectItemAtIndex:(NSInteger)index { [sectionController didDeselectItemAtIndex:localIndex]; } +- (void)didHighlightItemAtIndex:(NSInteger)index { + IGListSectionController *sectionController = [self sectionControllerForObjectIndex:index]; + const NSInteger localIndex = [self localIndexForSectionController:sectionController index:index]; + [sectionController didHighlightItemAtIndex:localIndex]; +} + +- (void)didUnhighlightItemAtIndex:(NSInteger)index { + IGListSectionController *sectionController = [self sectionControllerForObjectIndex:index]; + const NSInteger localIndex = [self localIndexForSectionController:sectionController index:index]; + [sectionController didUnhighlightItemAtIndex:localIndex]; +} + #pragma mark - IGListCollectionContext - (CGSize)containerSize { diff --git a/Source/Internal/IGListAdapter+UICollectionView.m b/Source/Internal/IGListAdapter+UICollectionView.m index c3c60d141..84d0973f4 100644 --- a/Source/Internal/IGListAdapter+UICollectionView.m +++ b/Source/Internal/IGListAdapter+UICollectionView.m @@ -150,6 +150,28 @@ - (void)collectionView:(UICollectionView *)collectionView didEndDisplayingSupple [self removeMapForView:view]; } +- (void)collectionView:(UICollectionView *)collectionView didHighlightItemAtIndexPath:(NSIndexPath *)indexPath { + // forward this method to the delegate b/c this implementation will steal the message from the proxy + id collectionViewDelegate = self.collectionViewDelegate; + if ([collectionViewDelegate respondsToSelector:@selector(collectionView:didHighlightItemAtIndexPath:)]) { + [collectionViewDelegate collectionView:collectionView didHighlightItemAtIndexPath:indexPath]; + } + + IGListSectionController * sectionController = [self sectionControllerForSection:indexPath.section]; + [sectionController didHighlightItemAtIndex:indexPath.item]; +} + +- (void)collectionView:(UICollectionView *)collectionView didUnhighlightItemAtIndexPath:(NSIndexPath *)indexPath { + // forward this method to the delegate b/c this implementation will steal the message from the proxy + id collectionViewDelegate = self.collectionViewDelegate; + if ([collectionViewDelegate respondsToSelector:@selector(collectionView:didUnhighlightItemAtIndexPath:)]) { + [collectionViewDelegate collectionView:collectionView didUnhighlightItemAtIndexPath:indexPath]; + } + + IGListSectionController * sectionController = [self sectionControllerForSection:indexPath.section]; + [sectionController didUnhighlightItemAtIndex:indexPath.item]; +} + #pragma mark - UICollectionViewDelegateFlowLayout - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { diff --git a/Source/Internal/IGListAdapterProxy.m b/Source/Internal/IGListAdapterProxy.m index e54381b8a..af3b2686f 100644 --- a/Source/Internal/IGListAdapterProxy.m +++ b/Source/Internal/IGListAdapterProxy.m @@ -21,6 +21,8 @@ static BOOL isInterceptedSelector(SEL sel) { sel == @selector(collectionView:didSelectItemAtIndexPath:) || sel == @selector(collectionView:willDisplayCell:forItemAtIndexPath:) || sel == @selector(collectionView:didEndDisplayingCell:forItemAtIndexPath:) || + sel == @selector(collectionView:didHighlightItemAtIndexPath:) || + sel == @selector(collectionView:didUnhighlightItemAtIndexPath:) || // UICollectionViewDelegateFlowLayout sel == @selector(collectionView:layout:sizeForItemAtIndexPath:) || sel == @selector(collectionView:layout:insetForSectionAtIndex:) || diff --git a/Tests/IGListAdapterE2ETests.m b/Tests/IGListAdapterE2ETests.m index 48067c150..00762f1c1 100644 --- a/Tests/IGListAdapterE2ETests.m +++ b/Tests/IGListAdapterE2ETests.m @@ -1552,4 +1552,53 @@ - (void)test_whenMassiveUpdate_thatUpdateApplied { [self waitForExpectationsWithTimeout:30 handler:nil]; } +- (void)test_whenInvalidatingInsideBatchUpdate_withSystemReleased_thatSystemNil_andCollectionViewDoesntCrashOnDealloc { + __weak id weakAdapter = nil; + __block BOOL executedItemUpdate = NO; + XCTestExpectation *expectation = genExpectation; + + @autoreleasepool { + self.dataSource.objects = @[ + genTestObject(@1, @"Bar"), + genTestObject(@0, @"Foo") + ]; + + UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:self.window.frame collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:collectionView]; + IGListAdapterUpdater *updater = [IGListAdapterUpdater new]; + IGListAdapter *adapter = [[IGListAdapter alloc] initWithUpdater:updater viewController:nil]; + adapter.dataSource = self.dataSource; + adapter.collectionView = collectionView; + [collectionView layoutIfNeeded]; + + IGTestDelegateController *section = [adapter sectionControllerForObject:self.dataSource.objects.firstObject]; + + __weak typeof(section) weakSection = section; + section.itemUpdateBlock = ^{ + executedItemUpdate = YES; + [weakSection.collectionContext invalidateLayoutForSectionController:weakSection completion:nil]; + }; + + self.dataSource.objects = @[ + genTestObject(@1, @"Bar"), + genTestObject(@0, @"Foo") + ]; + + [adapter performUpdatesAnimated:YES completion:^(BOOL finished) { + XCTAssertNotNil(collectionView); + XCTAssertNotNil(adapter); + [collectionView removeFromSuperview]; + [expectation fulfill]; + }]; + + weakAdapter = adapter; + XCTAssertNotNil(weakAdapter); + } + + [self waitForExpectationsWithTimeout:30 handler:^(NSError * _Nullable error) { + XCTAssertTrue(executedItemUpdate); + XCTAssertNil(weakAdapter); + }]; +} + @end diff --git a/Tests/IGListAdapterTests.m b/Tests/IGListAdapterTests.m index 543f76442..873dedda1 100644 --- a/Tests/IGListAdapterTests.m +++ b/Tests/IGListAdapterTests.m @@ -1198,6 +1198,74 @@ - (void)test_whenEndDisplayingSupplementaryView_thatCollectionViewDelegateReceiv [mockDelegate verify]; } +- (void)test_whenHighlightingCell_thatCollectionViewDelegateReceivesMethod { + self.dataSource.objects = @[@0, @1, @2]; + [self.adapter reloadDataWithCompletion:nil]; + + id mockDelegate = [OCMockObject mockForProtocol:@protocol(UICollectionViewDelegate)]; + self.adapter.collectionViewDelegate = mockDelegate; + + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0]; + [[mockDelegate expect] collectionView:self.collectionView didHighlightItemAtIndexPath:indexPath]; + + // simulates the collectionview telling its delegate that it was highlighted + [self.adapter collectionView:self.collectionView didHighlightItemAtIndexPath:indexPath]; + + [mockDelegate verify]; +} + +- (void)test_whenHighlightingCell_thatSectionControllerReceivesMethod { + self.dataSource.objects = @[@0, @1, @2]; + [self.adapter reloadDataWithCompletion:nil]; + + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0]; + + // simulates the collectionview telling its delegate that it was highlighted + [self.adapter collectionView:self.collectionView didHighlightItemAtIndexPath:indexPath]; + + IGListTestSection *s0 = [self.adapter sectionControllerForObject:@0]; + IGListTestSection *s1 = [self.adapter sectionControllerForObject:@1]; + IGListTestSection *s2 = [self.adapter sectionControllerForObject:@2]; + + XCTAssertTrue(s0.wasHighlighted); + XCTAssertFalse(s1.wasHighlighted); + XCTAssertFalse(s2.wasHighlighted); +} + +- (void)test_whenUnhighlightingCell_thatCollectionViewDelegateReceivesMethod { + self.dataSource.objects = @[@0, @1, @2]; + [self.adapter reloadDataWithCompletion:nil]; + + id mockDelegate = [OCMockObject mockForProtocol:@protocol(UICollectionViewDelegate)]; + self.adapter.collectionViewDelegate = mockDelegate; + + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0]; + [[mockDelegate expect] collectionView:self.collectionView didUnhighlightItemAtIndexPath:indexPath]; + + // simulates the collectionview telling its delegate that it was unhighlighted + [self.adapter collectionView:self.collectionView didUnhighlightItemAtIndexPath:indexPath]; + + [mockDelegate verify]; +} + +- (void)test_whenUnlighlightingCell_thatSectionControllerReceivesMethod { + self.dataSource.objects = @[@0, @1, @2]; + [self.adapter reloadDataWithCompletion:nil]; + + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0]; + + // simulates the collectionview telling its delegate that it was unhighlighted + [self.adapter collectionView:self.collectionView didUnhighlightItemAtIndexPath:indexPath]; + + IGListTestSection *s0 = [self.adapter sectionControllerForObject:@0]; + IGListTestSection *s1 = [self.adapter sectionControllerForObject:@1]; + IGListTestSection *s2 = [self.adapter sectionControllerForObject:@2]; + + XCTAssertTrue(s0.wasUnhighlighted); + XCTAssertFalse(s1.wasUnhighlighted); + XCTAssertFalse(s2.wasUnhighlighted); +} + - (void)test_whenDataSourceDoesntHandleObject_thatObjectIsDropped { // IGListTestAdapterDataSource does not handle NSStrings self.dataSource.objects = @[@1, @"dog", @2]; diff --git a/Tests/IGListBindingSectionControllerTests.m b/Tests/IGListBindingSectionControllerTests.m index ecf3eed62..496e5135c 100644 --- a/Tests/IGListBindingSectionControllerTests.m +++ b/Tests/IGListBindingSectionControllerTests.m @@ -105,6 +105,41 @@ - (void)test_whenUpdating_withAddedModels_thatCellsCorrectAndConfigured { [self waitForExpectationsWithTimeout:30 handler:nil]; } +- (void)test_whenUpdating_withNotUniqueModels_thatCellsCorrectAndConfigured { + [self setupWithObjects:@[ + [[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]], + ]]; + [self.adapter reloadObjects:@[[[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@"four", @4, @"seven", @7, @"seven", @10]]]]; + + IGTestNumberBindableCell *cell00 = [self cellAtSection:0 item:0]; + IGTestStringBindableCell *cell01 = [self cellAtSection:0 item:1]; + + XCTAssertEqualObjects(cell00.textField.text, @"7"); + XCTAssertEqualObjects(cell01.label.text, @"seven"); + XCTAssertNil([self cellAtSection:0 item:2]); + XCTAssertNil([self cellAtSection:0 item:3]); + + // "fake" batch updates to make sure that calling reload triggers a diffed batch update + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [self.adapter performBatchAnimated:YES updates:^(id batchContext){} completion:^(BOOL finished) { + IGTestStringBindableCell *batchedCell00 = [self cellAtSection:0 item:0]; + IGTestNumberBindableCell *batchedCell01 = [self cellAtSection:0 item:1]; + IGTestStringBindableCell *batchedCell02 = [self cellAtSection:0 item:2]; + IGTestNumberBindableCell *batchedCell03 = [self cellAtSection:0 item:3]; + IGTestNumberBindableCell *batchedCell04 = [self cellAtSection:0 item:4]; + + XCTAssertEqualObjects(batchedCell00.label.text, @"four"); + XCTAssertEqualObjects(batchedCell01.textField.text, @"4"); + XCTAssertEqualObjects(batchedCell02.label.text, @"seven"); + XCTAssertEqualObjects(batchedCell03.textField.text, @"7"); + XCTAssertEqualObjects(batchedCell04.textField.text, @"10"); + + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + - (void)test_whenSelectingCell_thatCorrectViewModelSelected { [self setupWithObjects:@[ [[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]], @@ -123,6 +158,24 @@ - (void)test_whenDeselectingCell_thatCorrectViewModelSelected { XCTAssertEqualObjects(section.deselectedViewModel, @"seven"); } +- (void)test_whenHighlightingCell_thatCorrectViewModelHighlighted { + [self setupWithObjects:@[ + [[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]], + ]]; + [self.adapter collectionView:self.collectionView didHighlightItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0]]; + IGTestDiffingSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.firstObject]; + XCTAssertEqualObjects(section.highlightedViewModel, @"seven"); +} + +- (void)test_whenUnhighlightingCell_thatCorrectViewModelUnhighlighted { + [self setupWithObjects:@[ + [[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]], + ]]; + [self.adapter collectionView:self.collectionView didUnhighlightItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0]]; + IGTestDiffingSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.firstObject]; + XCTAssertEqualObjects(section.unhighlightedViewModel, @"seven"); +} + - (void)test_whenDeselectingCell_withoutImplementation_thatNoOps { [self setupWithObjects:@[ [[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]], @@ -244,3 +297,4 @@ - (void)test_whenUpdatingManually_with2Updates_thatBothCompletionBlocksCalled { } @end + diff --git a/Tests/IGListStackSectionControllerTests.m b/Tests/IGListStackSectionControllerTests.m index 5554c7b19..54c666f73 100644 --- a/Tests/IGListStackSectionControllerTests.m +++ b/Tests/IGListStackSectionControllerTests.m @@ -592,6 +592,56 @@ - (void)test_whenDeselectingItems_thatChildSectionControllersSelected { XCTAssertTrue([stack2.sectionControllers[1] wasDeselected]); } +- (void)test_whenHighlightingItems_thatChildSectionControllersSelected { + [self setupWithObjects:@[ + [[IGTestObject alloc] initWithKey:@0 value:@[@1, @2, @3]], + [[IGTestObject alloc] initWithKey:@1 value:@[@1, @2, @3]], + [[IGTestObject alloc] initWithKey:@2 value:@[@1, @1]] + ]]; + + [self.adapter collectionView:self.collectionView didHighlightItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + [self.adapter collectionView:self.collectionView didHighlightItemAtIndexPath:[NSIndexPath indexPathForItem:2 inSection:1]]; + [self.adapter collectionView:self.collectionView didHighlightItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:2]]; + + IGListStackedSectionController *stack0 = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + IGListStackedSectionController *stack1 = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + IGListStackedSectionController *stack2 = [self.adapter sectionControllerForObject:self.dataSource.objects[2]]; + + XCTAssertTrue([stack0.sectionControllers[0] wasHighlighted]); + XCTAssertFalse([stack0.sectionControllers[1] wasHighlighted]); + XCTAssertFalse([stack0.sectionControllers[2] wasHighlighted]); + XCTAssertFalse([stack1.sectionControllers[0] wasHighlighted]); + XCTAssertTrue([stack1.sectionControllers[1] wasHighlighted]); + XCTAssertFalse([stack1.sectionControllers[2] wasHighlighted]); + XCTAssertFalse([stack2.sectionControllers[0] wasHighlighted]); + XCTAssertTrue([stack2.sectionControllers[1] wasHighlighted]); +} + +- (void)test_whenUnhighlightingItems_thatChildSectionControllersUnhighlighted { + [self setupWithObjects:@[ + [[IGTestObject alloc] initWithKey:@0 value:@[@1, @2, @3]], + [[IGTestObject alloc] initWithKey:@1 value:@[@1, @2, @3]], + [[IGTestObject alloc] initWithKey:@2 value:@[@1, @1]] + ]]; + + [self.adapter collectionView:self.collectionView didUnhighlightItemAtIndexPath:[NSIndexPath indexPathForItem:0 inSection:0]]; + [self.adapter collectionView:self.collectionView didUnhighlightItemAtIndexPath:[NSIndexPath indexPathForItem:2 inSection:1]]; + [self.adapter collectionView:self.collectionView didUnhighlightItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:2]]; + + IGListStackedSectionController *stack0 = [self.adapter sectionControllerForObject:self.dataSource.objects[0]]; + IGListStackedSectionController *stack1 = [self.adapter sectionControllerForObject:self.dataSource.objects[1]]; + IGListStackedSectionController *stack2 = [self.adapter sectionControllerForObject:self.dataSource.objects[2]]; + + XCTAssertTrue([stack0.sectionControllers[0] wasUnhighlighted]); + XCTAssertFalse([stack0.sectionControllers[1] wasUnhighlighted]); + XCTAssertFalse([stack0.sectionControllers[2] wasUnhighlighted]); + XCTAssertFalse([stack1.sectionControllers[0] wasUnhighlighted]); + XCTAssertTrue([stack1.sectionControllers[1] wasUnhighlighted]); + XCTAssertFalse([stack1.sectionControllers[2] wasUnhighlighted]); + XCTAssertFalse([stack2.sectionControllers[0] wasUnhighlighted]); + XCTAssertTrue([stack2.sectionControllers[1] wasUnhighlighted]); +} + - (void)test_whenUsingNibs_withStoryboards_thatCellsAreConfigured { [self setupWithObjects:@[ [[IGTestObject alloc] initWithKey:@0 value:@[@1, @"nib", @"storyboard"]], diff --git a/Tests/Objects/IGListTestSection.h b/Tests/Objects/IGListTestSection.h index f2650875c..0002a22e0 100644 --- a/Tests/Objects/IGListTestSection.h +++ b/Tests/Objects/IGListTestSection.h @@ -18,5 +18,7 @@ @property (nonatomic, assign) CGSize size; @property (nonatomic, assign) BOOL wasSelected; @property (nonatomic, assign) BOOL wasDeselected; +@property (nonatomic, assign) BOOL wasHighlighted; +@property (nonatomic, assign) BOOL wasUnhighlighted; @end diff --git a/Tests/Objects/IGListTestSection.m b/Tests/Objects/IGListTestSection.m index bb3e3a2d5..998e47e6a 100644 --- a/Tests/Objects/IGListTestSection.m +++ b/Tests/Objects/IGListTestSection.m @@ -50,4 +50,12 @@ - (void)didDeselectItemAtIndex:(NSInteger)index { self.wasDeselected = YES; } +- (void)didHighlightItemAtIndex:(NSInteger)index { + self.wasHighlighted = YES; +} + +- (void)didUnhighlightItemAtIndex:(NSInteger)index { + self.wasUnhighlighted = YES; +} + @end diff --git a/Tests/Objects/IGTestDiffingSectionController.h b/Tests/Objects/IGTestDiffingSectionController.h index a798bd788..f14ba0ea5 100644 --- a/Tests/Objects/IGTestDiffingSectionController.h +++ b/Tests/Objects/IGTestDiffingSectionController.h @@ -13,5 +13,7 @@ @property (nonatomic, strong) id selectedViewModel; @property (nonatomic, strong) id deselectedViewModel; +@property (nonatomic, strong) id highlightedViewModel; +@property (nonatomic, strong) id unhighlightedViewModel; @end diff --git a/Tests/Objects/IGTestDiffingSectionController.m b/Tests/Objects/IGTestDiffingSectionController.m index 589a5637f..ae3a9b12a 100644 --- a/Tests/Objects/IGTestDiffingSectionController.m +++ b/Tests/Objects/IGTestDiffingSectionController.m @@ -59,4 +59,12 @@ - (void)sectionController:(IGListBindingSectionController *)sectionController di self.deselectedViewModel = viewModel; } +- (void)sectionController:(IGListBindingSectionController *)sectionController didHighlightItemAtIndex:(NSInteger)index viewModel:(id)viewModel { + self.highlightedViewModel = viewModel; +} + +- (void)sectionController:(IGListBindingSectionController *)sectionController didUnhighlightItemAtIndex:(NSInteger)index viewModel:(id)viewModel { + self.unhighlightedViewModel = viewModel; +} + @end