Skip to content

Commit

Permalink
create global IGListAdapterDelegateAnnouncer
Browse files Browse the repository at this point in the history
Summary:
In a few cases, we need to listen to all `IGListAdapter` events. We bend over backwards to detect adapters and swap their delegate with a proxy objects. Lets make things simpler and build it right into `IGListKit` by allowing global announcers, starting with `IGListAdapterDelegate`, but we could expand to the others if we like this.

Generally, we want to avoid anything global, but given the complexity of the alternative, this feels like the better tradeoff.

Differential Revision: D64042609

fbshipit-source-id: 0ca6bada27e640fee5a231148427be41994e4d43
  • Loading branch information
Maxime Ollivier authored and facebook-github-bot committed Oct 9, 2024
1 parent 4bad7d5 commit 636f6b5
Show file tree
Hide file tree
Showing 12 changed files with 276 additions and 1 deletion.
6 changes: 6 additions & 0 deletions IGListKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -662,6 +664,7 @@
576029D92C61B91D006E50E2 /* IGListViewVisibilityTracker.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListViewVisibilityTracker.m; sourceTree = "<group>"; };
576029DA2C61B91D006E50E2 /* IGListViewVisibilityTrackerInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IGListViewVisibilityTrackerInternal.h; sourceTree = "<group>"; };
576029DB2C61B91D006E50E2 /* IGListUpdateCoalescer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListUpdateCoalescer.m; sourceTree = "<group>"; };
5766613D2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = IGListAdapterDelegateAnnouncerTests.m; sourceTree = "<group>"; };
57B22E662502AAB10055DC2F /* IGListTransitionData.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListTransitionData.m; sourceTree = "<group>"; };
57B22E692502AAB10055DC2F /* IGListTransitionData.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IGListTransitionData.h; sourceTree = "<group>"; };
57B22E712502AAC20055DC2F /* IGListBatchUpdateTransaction.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = IGListBatchUpdateTransaction.m; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1294,6 +1297,7 @@
children = (
294369AF1DB1B7AE0025F6E7 /* Assets */,
88144EE21D870EDC007C7F66 /* IGListAdapterE2ETests.m */,
5766613D2CB5A72500E20F73 /* IGListAdapterDelegateAnnouncerTests.m */,
29C4748A1DDF45E700AE68CE /* IGListAdapterProxyTests.m */,
8240C7F11DC284C300B3AAE7 /* IGListAdapterStoryboardTests.m */,
88144EE31D870EDC007C7F66 /* IGListAdapterTests.m */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 2 additions & 0 deletions Source/IGListKit/IGListAdapter.m
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#endif
#import "IGListAdapterUpdater.h"

#import "IGListAdapterDelegateAnnouncer.h"
#import "IGListArrayUtilsInternal.h"
#import "IGListDebugger.h"
#import "IGListDefaultExperiments.h"
Expand Down Expand Up @@ -55,6 +56,7 @@ - (instancetype)initWithUpdater:(id <IGListUpdatingDelegate>)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];
Expand Down
28 changes: 28 additions & 0 deletions Source/IGListKit/IGListAdapterDelegateAnnouncer.h
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>

#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<IGListAdapterDelegate>)listener;

/// Remove delegate
- (void)removeListener:(id<IGListAdapterDelegate>)listener;

@end

NS_ASSUME_NONNULL_END
49 changes: 49 additions & 0 deletions Source/IGListKit/IGListAdapterDelegateAnnouncer.m
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>

#import "IGListAdapterDelegateAnnouncerInternal.h"

@implementation IGListAdapterDelegateAnnouncer {
NSHashTable<id<IGListAdapterDelegate>> *_delegates;
}

+ (instancetype)sharedInstance {
static IGListAdapterDelegateAnnouncer *shared = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
shared = [self new];
});
return shared;
}

- (void)addListener:(id<IGListAdapterDelegate>)listener {
if (!_delegates) {
_delegates = [NSHashTable weakObjectsHashTable];
}

[_delegates addObject:listener];
}

- (void)removeListener:(id<IGListAdapterDelegate>)listener {
[_delegates removeObject:listener];
}

- (void)announceCellDisplayWithAdapter:(IGListAdapter *)listAdapter object:(id)object index:(NSInteger)index {
for (id<IGListAdapterDelegate> delegate in [_delegates allObjects]) {
[delegate listAdapter:listAdapter willDisplayObject:object atIndex:index];
}
}

- (void)announceCellEndDisplayWithAdapter:(IGListAdapter *)listAdapter object:(id)object index:(NSInteger)index {
for (id<IGListAdapterDelegate> delegate in [_delegates allObjects]) {
[delegate listAdapter:listAdapter didEndDisplayingObject:object atIndex:index];
}
}

@end
19 changes: 19 additions & 0 deletions Source/IGListKit/Internal/IGListAdapterDelegateAnnouncerInternal.h
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions Source/IGListKit/Internal/IGListAdapterInternal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Source/IGListKit/Internal/IGListDisplayHandler.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

@class IGListAdapter;
@class IGListSectionController;
@class IGListAdapterDelegateAnnouncer;



Expand Down
5 changes: 4 additions & 1 deletion Source/IGListKit/Internal/IGListDisplayHandler.m
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
#else
#import <IGListDiffKit/IGListAssert.h>
#endif
#import "IGListAdapter.h"
#import "IGListAdapterInternal.h"
#import "IGListAdapterDelegateAnnouncerInternal.h"
#import "IGListDisplayDelegate.h"
#import "IGListSectionController.h"
#import "IGListSectionControllerInternal.h"
Expand Down Expand Up @@ -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];
}
Expand All @@ -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];
}
}

Expand Down
161 changes: 161 additions & 0 deletions Tests/IGListAdapterDelegateAnnouncerTests.m
Original file line number Diff line number Diff line change
@@ -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 <XCTest/XCTest.h>

#import <OCMock/OCMock.h>

#import <IGListKit/IGListKit.h>

#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<IGListTestCaseDataSource> dataSource1;
@property (nonatomic, strong) id<IGListTestCaseDataSource> 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
1 change: 1 addition & 0 deletions spm/Sources/IGListKit/IGListAdapterDelegateAnnouncer.m

0 comments on commit 636f6b5

Please # to comment.