diff --git a/IGListKit.xcodeproj/project.pbxproj b/IGListKit.xcodeproj/project.pbxproj index 8758799f7..cd49879a3 100644 --- a/IGListKit.xcodeproj/project.pbxproj +++ b/IGListKit.xcodeproj/project.pbxproj @@ -87,6 +87,8 @@ 576029E72C61B91D006E50E2 /* IGListViewVisibilityTrackerInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 576029DA2C61B91D006E50E2 /* IGListViewVisibilityTrackerInternal.h */; }; 576029E82C61B91D006E50E2 /* IGListUpdateCoalescer.m in Sources */ = {isa = PBXBuildFile; fileRef = 576029DB2C61B91D006E50E2 /* IGListUpdateCoalescer.m */; }; 576029E92C61B91D006E50E2 /* IGListUpdateCoalescer.m in Sources */ = {isa = PBXBuildFile; fileRef = 576029DB2C61B91D006E50E2 /* IGListUpdateCoalescer.m */; }; + 5766613E2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 5766613D2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m */; }; + 5766613F2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 5766613D2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m */; }; 57B22E6C2502AAB20055DC2F /* IGListTransitionData.m in Sources */ = {isa = PBXBuildFile; fileRef = 57B22E662502AAB10055DC2F /* IGListTransitionData.m */; }; 57B22E6F2502AAB20055DC2F /* IGListTransitionData.h in Headers */ = {isa = PBXBuildFile; fileRef = 57B22E692502AAB10055DC2F /* IGListTransitionData.h */; settings = {ATTRIBUTES = (Public, ); }; }; 57B22E7F2502AAC40055DC2F /* IGListBatchUpdateTransaction.m in Sources */ = {isa = PBXBuildFile; fileRef = 57B22E712502AAC20055DC2F /* IGListBatchUpdateTransaction.m */; }; @@ -662,6 +664,7 @@ 576029D92C61B91D006E50E2 /* IGListViewVisibilityTracker.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListViewVisibilityTracker.m; sourceTree = ""; }; 576029DA2C61B91D006E50E2 /* IGListViewVisibilityTrackerInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IGListViewVisibilityTrackerInternal.h; sourceTree = ""; }; 576029DB2C61B91D006E50E2 /* IGListUpdateCoalescer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListUpdateCoalescer.m; sourceTree = ""; }; + 5766613D2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = IGListAdapterDelegateAnnouncerTests.m; sourceTree = ""; }; 57B22E662502AAB10055DC2F /* IGListTransitionData.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListTransitionData.m; sourceTree = ""; }; 57B22E692502AAB10055DC2F /* IGListTransitionData.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IGListTransitionData.h; sourceTree = ""; }; 57B22E712502AAC20055DC2F /* IGListBatchUpdateTransaction.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListBatchUpdateTransaction.m; sourceTree = ""; }; @@ -1294,6 +1297,7 @@ children = ( 294369AF1DB1B7AE0025F6E7 /* Assets */, 88144EE21D870EDC007C7F66 /* IGListAdapterE2ETests.m */, + 5766613D2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m */, 29C4748A1DDF45E700AE68CE /* IGListAdapterProxyTests.m */, 8240C7F11DC284C300B3AAE7 /* IGListAdapterStoryboardTests.m */, 88144EE31D870EDC007C7F66 /* IGListAdapterTests.m */, @@ -2131,6 +2135,7 @@ 29C4748F1DDF460500AE68CE /* IGListDiffResultTests.m in Sources */, F1ED68B729E9B3B9003744F8 /* IGListTransactionTests.m in Sources */, F1ED68BE29E9B41A003744F8 /* IGListContentInsetTests.m in Sources */, + 5766613F2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m in Sources */, 885FE2421DC51B86009CE2B4 /* IGTestSingleStoryboardItemDataSource.m in Sources */, 885FE2301DC51B76009CE2B4 /* IGListDiffTests.m in Sources */, 885FE22E1DC51B76009CE2B4 /* IGListBatchUpdateDataTests.m in Sources */, @@ -2253,6 +2258,7 @@ F1855A4C29BC565600558D18 /* IGListDiffDescriptionStringTests.m in Sources */, 821BC4D31DB981AB00172ED0 /* IGTestSingleStoryboardItemDataSource.m in Sources */, 298DDA3D1E3B170400F76F50 /* IGLayoutTestSection.m in Sources */, + 5766613E2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m in Sources */, 298DDA091E3AE31D00F76F50 /* IGTestDiffingSectionController.m in Sources */, 88144F151D870EDC007C7F66 /* IGListTestSection.m in Sources */, 82914C5B1E6E2DEC0066C2F8 /* IGListTestContainerSizeSection.m in Sources */, diff --git a/Source/IGListKit/IGListAdapter.m b/Source/IGListKit/IGListAdapter.m index 42d146582..edbfc84ce 100644 --- a/Source/IGListKit/IGListAdapter.m +++ b/Source/IGListKit/IGListAdapter.m @@ -14,6 +14,7 @@ #endif #import "IGListAdapterUpdater.h" +#import "IGListAdapterDelegateAnnouncer.h" #import "IGListArrayUtilsInternal.h" #import "IGListDebugger.h" #import "IGListDefaultExperiments.h" @@ -55,6 +56,7 @@ - (instancetype)initWithUpdater:(id )updater NSMapTable *table = [[NSMapTable alloc] initWithKeyPointerFunctions:keyFunctions valuePointerFunctions:valueFunctions capacity:0]; _sectionMap = [[IGListSectionMap alloc] initWithMapTable:table]; + _globalDelegateAnnouncer = [IGListAdapterDelegateAnnouncer sharedInstance]; _displayHandler = [IGListDisplayHandler new]; _workingRangeHandler = [[IGListWorkingRangeHandler alloc] initWithWorkingRangeSize:workingRangeSize]; _updateListeners = [NSHashTable weakObjectsHashTable]; diff --git a/Source/IGListKit/IGListAdapterDelegateAnnouncer.h b/Source/IGListKit/IGListAdapterDelegateAnnouncer.h new file mode 100644 index 000000000..d0ab30b1c --- /dev/null +++ b/Source/IGListKit/IGListAdapterDelegateAnnouncer.h @@ -0,0 +1,28 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "IGListAdapterDelegate.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface IGListAdapterDelegateAnnouncer : NSObject + +/// Default announcer for all `IGListAdapter` ++ (instancetype)sharedInstance; + +/// Add a delegate that will receive callbacks for all `IGListAdapter`. +/// This is a weak reference, so you don't need to remove it on dealloc. +- (void)addListener:(id)listener; + +/// Remove delegate +- (void)removeListener:(id)listener; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Source/IGListKit/IGListAdapterDelegateAnnouncer.m b/Source/IGListKit/IGListAdapterDelegateAnnouncer.m new file mode 100644 index 000000000..32ce56f0b --- /dev/null +++ b/Source/IGListKit/IGListAdapterDelegateAnnouncer.m @@ -0,0 +1,49 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "IGListAdapterDelegateAnnouncerInternal.h" + +@implementation IGListAdapterDelegateAnnouncer { + NSHashTable> *_delegates; +} + ++ (instancetype)sharedInstance { + static IGListAdapterDelegateAnnouncer *shared = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + shared = [self new]; + }); + return shared; +} + +- (void)addListener:(id)listener { + if (!_delegates) { + _delegates = [NSHashTable weakObjectsHashTable]; + } + + [_delegates addObject:listener]; +} + +- (void)removeListener:(id)listener { + [_delegates removeObject:listener]; +} + +- (void)announceCellDisplayWithAdapter:(IGListAdapter *)listAdapter object:(id)object index:(NSInteger)index { + for (id delegate in [_delegates allObjects]) { + [delegate listAdapter:listAdapter willDisplayObject:object atIndex:index]; + } +} + +- (void)announceCellEndDisplayWithAdapter:(IGListAdapter *)listAdapter object:(id)object index:(NSInteger)index { + for (id delegate in [_delegates allObjects]) { + [delegate listAdapter:listAdapter didEndDisplayingObject:object atIndex:index]; + } +} + +@end diff --git a/Source/IGListKit/Internal/IGListAdapterDelegateAnnouncerInternal.h b/Source/IGListKit/Internal/IGListAdapterDelegateAnnouncerInternal.h new file mode 100644 index 000000000..9ad989034 --- /dev/null +++ b/Source/IGListKit/Internal/IGListAdapterDelegateAnnouncerInternal.h @@ -0,0 +1,19 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "IGListAdapterDelegateAnnouncer.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface IGListAdapterDelegateAnnouncer () + +- (void)announceCellDisplayWithAdapter:(IGListAdapter *)listAdapter object:(id)object index:(NSInteger)index; +- (void)announceCellEndDisplayWithAdapter:(IGListAdapter *)listAdapter object:(id)object index:(NSInteger)index; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Source/IGListKit/Internal/IGListAdapterInternal.h b/Source/IGListKit/Internal/IGListAdapterInternal.h index 5d22cfc25..1c2843c72 100644 --- a/Source/IGListKit/Internal/IGListAdapterInternal.h +++ b/Source/IGListKit/Internal/IGListAdapterInternal.h @@ -47,6 +47,9 @@ IGListBatchContext @property (nonatomic, strong, nullable) IGListAdapterProxy *delegateProxy; +// Set as a property for unit testing +@property (nonatomic, strong, nullable) IGListAdapterDelegateAnnouncer *globalDelegateAnnouncer; + @property (nonatomic, strong, nullable) UIView *emptyBackgroundView; // We need to special case interactive section moves that are moved to the last position diff --git a/Source/IGListKit/Internal/IGListDisplayHandler.h b/Source/IGListKit/Internal/IGListDisplayHandler.h index eeae152da..8452ef358 100644 --- a/Source/IGListKit/Internal/IGListDisplayHandler.h +++ b/Source/IGListKit/Internal/IGListDisplayHandler.h @@ -15,6 +15,7 @@ @class IGListAdapter; @class IGListSectionController; +@class IGListAdapterDelegateAnnouncer; diff --git a/Source/IGListKit/Internal/IGListDisplayHandler.m b/Source/IGListKit/Internal/IGListDisplayHandler.m index fe23b4d32..aa470dc96 100644 --- a/Source/IGListKit/Internal/IGListDisplayHandler.m +++ b/Source/IGListKit/Internal/IGListDisplayHandler.m @@ -12,7 +12,8 @@ #else #import #endif -#import "IGListAdapter.h" +#import "IGListAdapterInternal.h" +#import "IGListAdapterDelegateAnnouncerInternal.h" #import "IGListDisplayDelegate.h" #import "IGListSectionController.h" #import "IGListSectionControllerInternal.h" @@ -55,6 +56,7 @@ - (void)_willDisplayReusableView:(UICollectionReusableView *)view if ([visibleListSections countForObject:sectionController] == 0) { [sectionController willDisplaySectionControllerWithListAdapter:listAdapter]; [listAdapter.delegate listAdapter:listAdapter willDisplayObject:object atIndex:indexPath.section]; + [listAdapter.globalDelegateAnnouncer announceCellDisplayWithAdapter:listAdapter object:object index:indexPath.section]; } [visibleListSections addObject:sectionController]; } @@ -80,6 +82,7 @@ - (void)_didEndDisplayingReusableView:(UICollectionReusableView *)view if ([visibleSections countForObject:sectionController] == 0) { [sectionController didEndDisplayingSectionControllerWithListAdapter:listAdapter]; [listAdapter.delegate listAdapter:listAdapter didEndDisplayingObject:object atIndex:section]; + [listAdapter.globalDelegateAnnouncer announceCellEndDisplayWithAdapter:listAdapter object:object index:indexPath.section]; } } diff --git a/Tests/IGListAdapterDelegateAnnouncerTests.m b/Tests/IGListAdapterDelegateAnnouncerTests.m new file mode 100644 index 000000000..64eaa48a9 --- /dev/null +++ b/Tests/IGListAdapterDelegateAnnouncerTests.m @@ -0,0 +1,161 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +#import + +#import "IGListAdapterInternal.h" +#import "IGListAdapterUpdater.h" +#import "IGListTestHelpers.h" +#import "IGTestDelegateDataSource.h" +#import "IGTestObject.h" +#import "IGListAdapterDelegateAnnouncer.h" + +@interface IGListAdapterDelegateAnnouncerTests : XCTestCase + +// These objects are created for you in -setUp +@property (nonatomic, strong) UIWindow *window; +@property (nonatomic, strong) UIViewController *viewController; +@property (nonatomic, strong) IGListAdapterDelegateAnnouncer *announcer; + +@property (nonatomic, strong) UICollectionView *collectionView1; +@property (nonatomic, strong) UICollectionView *collectionView2; +@property (nonatomic, strong) id dataSource1; +@property (nonatomic, strong) id dataSource2; +@property (nonatomic, strong) IGListAdapter *adapter1; +@property (nonatomic, strong) IGListAdapter *adapter2; + +@end + +@implementation IGListAdapterDelegateAnnouncerTests + +- (void)setUp { + [super setUp]; + + self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + self.viewController = [UIViewController new]; + self.announcer = [IGListAdapterDelegateAnnouncer new]; + + self.collectionView1 = [[UICollectionView alloc] initWithFrame:self.window.bounds collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:self.collectionView1]; + + self.collectionView2 = [[UICollectionView alloc] initWithFrame:self.window.bounds collectionViewLayout:[UICollectionViewFlowLayout new]]; + [self.window addSubview:self.collectionView2]; + + self.dataSource1 = [IGTestDelegateDataSource new]; + self.dataSource2 = [IGTestDelegateDataSource new]; + + self.adapter1 = [[IGListAdapter alloc] initWithUpdater:[IGListAdapterUpdater new] viewController:self.viewController]; + self.adapter1.globalDelegateAnnouncer = self.announcer; + + self.adapter2 = [[IGListAdapter alloc] initWithUpdater:[IGListAdapterUpdater new] viewController:self.viewController]; + self.adapter2.globalDelegateAnnouncer = self.announcer; +} + +- (void)setupAdapter1WithObjects:(NSArray *)objects { + self.dataSource1.objects = objects; + self.adapter1.collectionView = self.collectionView1; + self.adapter1.dataSource = self.dataSource1; + [self.collectionView1 layoutIfNeeded]; +} + +- (void)setupAdapter2WithObjects:(NSArray *)objects { + self.dataSource2.objects = objects; + self.adapter2.collectionView = self.collectionView2; + self.adapter2.dataSource = self.dataSource2; + [self.collectionView2 layoutIfNeeded]; +} + +#pragma mark - Single adapter, multiple listeners + +- (void)test_whenShowingOneItem_withTwoListeners_withOneAdapter_thatBothListenersReceivesWillDisplay{ + [self setupAdapter1WithObjects:@[]]; + + IGTestObject *const object = genTestObject(@1, @1); + self.dataSource1.objects = @[ + object + ]; + + id mockDisplayHandler1 = [OCMockObject mockForProtocol:@protocol(IGListAdapterDelegate)]; + [self.announcer addListener:mockDisplayHandler1]; + [[mockDisplayHandler1 expect] listAdapter:self.adapter1 willDisplayObject:object atIndex:0]; + + id mockDisplayHandler2 = [OCMockObject mockForProtocol:@protocol(IGListAdapterDelegate)]; + [self.announcer addListener:mockDisplayHandler2]; + [[mockDisplayHandler2 expect] listAdapter:self.adapter1 willDisplayObject:object atIndex:0]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter1 performUpdatesAnimated:NO completion:^(BOOL finished2) { + [mockDisplayHandler1 verify]; + [mockDisplayHandler2 verify]; + XCTAssertTrue(finished2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +- (void)test_whenRemovignOneItem_withTwoListeners_withOneAdapter_thatBothListenersReceivesEndDisplay { + IGTestObject *const object = genTestObject(@1, @1); + [self setupAdapter1WithObjects:@[object]]; + + self.dataSource1.objects = @[]; + + id mockDisplayHandler1 = [OCMockObject mockForProtocol:@protocol(IGListAdapterDelegate)]; + [self.announcer addListener:mockDisplayHandler1]; + [[mockDisplayHandler1 expect] listAdapter:self.adapter1 didEndDisplayingObject:object atIndex:0]; + + id mockDisplayHandler2 = [OCMockObject mockForProtocol:@protocol(IGListAdapterDelegate)]; + [self.announcer addListener:mockDisplayHandler2]; + [[mockDisplayHandler2 expect] listAdapter:self.adapter1 didEndDisplayingObject:object atIndex:0]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter1 performUpdatesAnimated:NO completion:^(BOOL finished2) { + [mockDisplayHandler1 verify]; + [mockDisplayHandler2 verify]; + XCTAssertTrue(finished2); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +#pragma mark - Two adapters, single listener + +- (void)test_whenShowingTwoItems_withOneListeners_withTwoAdapters_thatBothItemsSendWillDisplay { + [self setupAdapter1WithObjects:@[]]; + [self setupAdapter2WithObjects:@[]]; + + IGTestObject *const object1 = genTestObject(@1, @1); + self.dataSource1.objects = @[ + object1 + ]; + + IGTestObject *const object2 = genTestObject(@1, @1); + self.dataSource2.objects = @[ + object2 + ]; + + id mockDisplayHandler = [OCMockObject mockForProtocol:@protocol(IGListAdapterDelegate)]; + [self.announcer addListener:mockDisplayHandler]; + [[mockDisplayHandler expect] listAdapter:self.adapter1 willDisplayObject:object1 atIndex:0]; + [[mockDisplayHandler expect] listAdapter:self.adapter2 willDisplayObject:object2 atIndex:0]; + + XCTestExpectation *expectation = genExpectation; + [self.adapter1 performUpdatesAnimated:NO completion:^(BOOL finished1) { + [self.adapter2 performUpdatesAnimated:NO completion:^(BOOL finished2) { + [mockDisplayHandler verify]; + XCTAssertTrue(finished1); + XCTAssertTrue(finished2); + [expectation fulfill]; + }]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + +@end diff --git a/spm/Sources/IGListKit/IGListAdapterDelegateAnnouncer.m b/spm/Sources/IGListKit/IGListAdapterDelegateAnnouncer.m new file mode 120000 index 000000000..67497fd23 --- /dev/null +++ b/spm/Sources/IGListKit/IGListAdapterDelegateAnnouncer.m @@ -0,0 +1 @@ +../../../Source/IGListKit/IGListAdapterDelegateAnnouncer.m \ No newline at end of file diff --git a/spm/Sources/IGListKit/IGListAdapterDelegateAnnouncerInternal.h b/spm/Sources/IGListKit/IGListAdapterDelegateAnnouncerInternal.h new file mode 120000 index 000000000..ffd76f27e --- /dev/null +++ b/spm/Sources/IGListKit/IGListAdapterDelegateAnnouncerInternal.h @@ -0,0 +1 @@ +../../../Source/IGListKit/Internal/IGListAdapterDelegateAnnouncerInternal.h \ No newline at end of file diff --git a/spm/Sources/IGListKit/include/IGListAdapterDelegateAnnouncer.h b/spm/Sources/IGListKit/include/IGListAdapterDelegateAnnouncer.h new file mode 120000 index 000000000..c59a56bcc --- /dev/null +++ b/spm/Sources/IGListKit/include/IGListAdapterDelegateAnnouncer.h @@ -0,0 +1 @@ +../../../../Source/IGListKit/IGListAdapterDelegateAnnouncer.h \ No newline at end of file