diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ae82420..4ff5262d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ The changelog for `IGListKit`. Also see the [releases](https://github.com/instag ### Enhancements +- Added support for iOS 13 Context Menus with `contextMenuConfigurationForItemAt` method. [Jérôme B.](https://github.com/jjbourdev) [(#1430)](https://github.com/Instagram/IGListKit/pull/1430). + - Added `shouldSelectItemAtIndex:` to `IGListSectionController` . [dirtmelon](https://github.com/dirtmelon) - Added [Mac Catalyst](https://developer.apple.com/mac-catalyst/) support. [Petro Rovenskyy](https://github.com/3a4oT/) diff --git a/Examples/Examples-iOS/IGListKitExamples/SectionControllers/MonthSectionController.swift b/Examples/Examples-iOS/IGListKitExamples/SectionControllers/MonthSectionController.swift index fed88acf3..52cdbcc1b 100644 --- a/Examples/Examples-iOS/IGListKitExamples/SectionControllers/MonthSectionController.swift +++ b/Examples/Examples-iOS/IGListKitExamples/SectionControllers/MonthSectionController.swift @@ -92,4 +92,26 @@ final class MonthSectionController: ListBindingSectionController, func sectionController(_ sectionController: ListBindingSectionController, didUnhighlightItemAt index: Int, viewModel: Any) {} + @available(iOS 13.0, *) + func sectionController(_ sectionController: ListBindingSectionController, contextMenuConfigurationForItemAt index: Int, point: CGPoint, viewModel: Any) -> UIContextMenuConfiguration? { + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + // Create an action for sharing + let share = UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { _ in + // Show share sheet + } + + // Create an action for copy + let rename = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in + // Perform copy + } + + // Create an action for delete with destructive attributes (highligh in red) + let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in + // Perform delete + } + + // Create a UIMenu with all the actions as children + return UIMenu(title: "", children: [share, rename, delete]) + } + } } diff --git a/Source/IGListKit/IGListSectionController.h b/Source/IGListKit/IGListSectionController.h index b7e5ec50c..8e18f372c 100644 --- a/Source/IGListKit/IGListSectionController.h +++ b/Source/IGListKit/IGListSectionController.h @@ -120,6 +120,40 @@ NS_SWIFT_NAME(ListSectionController) */ - (void)didUnhighlightItemAtIndex:(NSInteger)index; +/** + Tells the section controller that the cell has requested a menu configuration. + + @param index The index of the cell that requested the menu. + @param point The point of the tap on the cell. + + @return An object that conforms to `UIContextMenuConfiguration` + + @note The default implementation does nothing. **Calling super is not required.** + */ +- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos); + +/** + Tells the section controller that the cell has requested a preview for context menu highlight. + + @param configuration Context menu configuration. + + @return An object that conforms to `UITargetedPreview` + + @note The default implementation does nothing. **Calling super is not required.** + */ +- (UITargetedPreview * _Nullable)previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos); + +/** + Tells the section controller that the cell has requested a preview for context menu dismiss. + + @param configuration Context menu configuration. + + @return An object that conforms to `UITargetedPreview` + + @note The default implementation does nothing. **Calling super is not required.** + */ +- (UITargetedPreview * _Nullable)previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos); + /** Identifies whether an object can be moved through interactive reordering. diff --git a/Source/IGListKit/IGListSectionController.m b/Source/IGListKit/IGListSectionController.m index c60cc1329..b8dcbd180 100644 --- a/Source/IGListKit/IGListSectionController.m +++ b/Source/IGListKit/IGListSectionController.m @@ -97,6 +97,18 @@ - (void)didHighlightItemAtIndex:(NSInteger)index {} - (void)didUnhighlightItemAtIndex:(NSInteger)index {} +- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point { + return nil; +} + +- (UITargetedPreview * _Nullable)previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration { + return nil; +} + +- (UITargetedPreview * _Nullable)previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration { + return nil; +} + - (BOOL)canMoveItemAtIndex:(NSInteger)index { return NO; } diff --git a/Source/IGListKit/Internal/IGListAdapter+UICollectionView.m b/Source/IGListKit/Internal/IGListAdapter+UICollectionView.m index c710e9613..5844c6d6c 100644 --- a/Source/IGListKit/Internal/IGListAdapter+UICollectionView.m +++ b/Source/IGListKit/Internal/IGListAdapter+UICollectionView.m @@ -18,6 +18,12 @@ #import "IGListAdapterInternal.h" +@interface IGListAdapter () + +@property (nonatomic, strong) NSMapTable *configurationToSectionController; + +@end + @implementation IGListAdapter (UICollectionView) #pragma mark - UICollectionViewDataSource @@ -269,6 +275,47 @@ - (void)collectionView:(UICollectionView *)collectionView didUnhighlightItemAtIn [sectionController didUnhighlightItemAtIndex:indexPath.item]; } +#if !TARGET_OS_TV +- (UIContextMenuConfiguration *)collectionView:(UICollectionView *)collectionView contextMenuConfigurationForItemAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point API_AVAILABLE(ios(13.0)) { + // 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:contextMenuConfigurationForItemAtIndexPath:point:)]) { + return [collectionViewDelegate collectionView:collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:point]; + } + + IGListSectionController * sectionController = [self sectionControllerForSection:indexPath.section]; + + UIContextMenuConfiguration *configuration = [sectionController contextMenuConfigurationForItemAtIndex:indexPath.item point:point]; + if (configuration) { + if (!self.configurationToSectionController) { + self.configurationToSectionController = [NSMapTable weakToWeakObjectsMapTable]; + } + [self.configurationToSectionController setObject:configuration forKey:sectionController]; + } + return nil; +} + +- (UITargetedPreview *)collectionView:(UICollectionView *)collectionView previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)){ + id collectionViewDelegate = self.collectionViewDelegate; + if ([collectionViewDelegate respondsToSelector:@selector(collectionView:previewForHighlightingContextMenuWithConfiguration:)]) { + return [collectionViewDelegate collectionView:collectionView previewForHighlightingContextMenuWithConfiguration:configuration]; + } + + IGListSectionController * sectionController = [self.configurationToSectionController objectForKey:configuration]; + return [sectionController previewForHighlightingContextMenuWithConfiguration:configuration]; +} + +- (UITargetedPreview *)collectionView:(UICollectionView *)collectionView previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)){ + id collectionViewDelegate = self.collectionViewDelegate; + if ([collectionViewDelegate respondsToSelector:@selector(collectionView:previewForDismissingContextMenuWithConfiguration:)]) { + return [collectionViewDelegate collectionView:collectionView previewForDismissingContextMenuWithConfiguration:configuration]; + } + + IGListSectionController * sectionController = [self.configurationToSectionController objectForKey:configuration]; + return [sectionController previewForDismissingContextMenuWithConfiguration:configuration]; +} +#endif + #pragma mark - UICollectionViewDelegateFlowLayout - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { diff --git a/Source/IGListKit/Internal/IGListAdapterProxy.m b/Source/IGListKit/Internal/IGListAdapterProxy.m index f7733b0a0..7186bc228 100644 --- a/Source/IGListKit/Internal/IGListAdapterProxy.m +++ b/Source/IGListKit/Internal/IGListAdapterProxy.m @@ -33,6 +33,9 @@ static BOOL isInterceptedSelector(SEL sel) { sel == @selector(collectionView:didDeselectItemAtIndexPath:) || sel == @selector(collectionView:didHighlightItemAtIndexPath:) || sel == @selector(collectionView:didUnhighlightItemAtIndexPath:) || + sel == @selector(collectionView:contextMenuConfigurationForItemAtIndexPath:point:) || + sel == @selector(collectionView:previewForHighlightingContextMenuWithConfiguration:) || + sel == @selector(collectionView:previewForDismissingContextMenuWithConfiguration:) || // UICollectionViewDelegateFlowLayout sel == @selector(collectionView:layout:sizeForItemAtIndexPath:) || sel == @selector(collectionView:layout:insetForSectionAtIndex:) || diff --git a/Tests/IGListAdapterTests.m b/Tests/IGListAdapterTests.m index d5ba206d0..a77f999d3 100644 --- a/Tests/IGListAdapterTests.m +++ b/Tests/IGListAdapterTests.m @@ -1339,7 +1339,7 @@ - (void)test_whenUnhighlightingCell_thatCollectionViewDelegateReceivesMethod { [mockDelegate verify]; } -- (void)test_whenUnlighlightingCell_thatSectionControllerReceivesMethod { +- (void)test_whenUnhighlightingCell_thatSectionControllerReceivesMethod { self.dataSource.objects = @[@0, @1, @2]; [self.adapter reloadDataWithCompletion:nil]; @@ -1357,6 +1357,40 @@ - (void)test_whenUnlighlightingCell_thatSectionControllerReceivesMethod { XCTAssertFalse(s2.wasUnhighlighted); } +- (void)test_whenContextMenuAskedCell_thatCollectionViewDelegateReceivesMethod API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) { + 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 contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero]; + + // simulates the collectionview telling its delegate that it needs the context menu configuration + [self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero]; + + [mockDelegate verify]; +} + +- (void)test_whenContextMenuAskedCell_thatSectionControllerReceivesMethod API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) { + 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 needs the context menu configuration + [self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:CGPointZero]; + + IGListTestSection *s0 = [self.adapter sectionControllerForObject:@0]; + IGListTestSection *s1 = [self.adapter sectionControllerForObject:@1]; + IGListTestSection *s2 = [self.adapter sectionControllerForObject:@2]; + + XCTAssertTrue(s0.requestedContextMenu); + XCTAssertFalse(s1.requestedContextMenu); + XCTAssertFalse(s2.requestedContextMenu); +} + - (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 f9a210964..172916ea6 100644 --- a/Tests/IGListBindingSectionControllerTests.m +++ b/Tests/IGListBindingSectionControllerTests.m @@ -190,6 +190,15 @@ - (void)test_whenUnhighlightingCell_thatCorrectViewModelUnhighlighted { XCTAssertEqualObjects(section.unhighlightedViewModel, @"seven"); } +- (void)test_whenContextMenuAskedCell_thatCorrectViewModelRetrieved API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) { + [self setupWithObjects:@[ + [[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]], + ]]; + [self.adapter collectionView:self.collectionView contextMenuConfigurationForItemAtIndexPath:[NSIndexPath indexPathForItem:1 inSection:0] point:CGPointZero]; + IGTestDiffingSectionController *section = [self.adapter sectionControllerForObject:self.dataSource.objects.firstObject]; + XCTAssertEqualObjects(section.contextMenuViewModel, @"seven"); +} + - (void)test_whenDeselectingCell_withoutImplementation_thatNoOps { [self setupWithObjects:@[ [[IGTestDiffingObject alloc] initWithKey:@1 objects:@[@7, @"seven"]], diff --git a/Tests/Objects/IGListTestSection.h b/Tests/Objects/IGListTestSection.h index 61911b868..fb17542f3 100644 --- a/Tests/Objects/IGListTestSection.h +++ b/Tests/Objects/IGListTestSection.h @@ -19,5 +19,6 @@ @property (nonatomic, assign) BOOL wasHighlighted; @property (nonatomic, assign) BOOL wasUnhighlighted; @property (nonatomic, assign) BOOL wasDisplayed; +@property (nonatomic, assign) BOOL requestedContextMenu; @end diff --git a/Tests/Objects/IGListTestSection.m b/Tests/Objects/IGListTestSection.m index 60412f6f0..cf662d00c 100644 --- a/Tests/Objects/IGListTestSection.m +++ b/Tests/Objects/IGListTestSection.m @@ -60,6 +60,11 @@ - (void)didUnhighlightItemAtIndex:(NSInteger)index { self.wasUnhighlighted = YES; } +- (UIContextMenuConfiguration * _Nullable)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point { + self.requestedContextMenu = YES; + return nil; +} + #pragma mark - IGListDisplayDelegate - (void)listAdapter:(IGListAdapter *)listAdapter willDisplaySectionController:(IGListSectionController *)sectionController { diff --git a/Tests/Objects/IGTestBindingWithoutDeselectionDelegate.m b/Tests/Objects/IGTestBindingWithoutDeselectionDelegate.m index ce8426914..4f4f1d2af 100644 --- a/Tests/Objects/IGTestBindingWithoutDeselectionDelegate.m +++ b/Tests/Objects/IGTestBindingWithoutDeselectionDelegate.m @@ -31,4 +31,12 @@ - (void)sectionController:(nonnull IGListBindingSectionController *)sectionContr viewModel:(nonnull id)viewModel { } + +- (UIContextMenuConfiguration * _Nullable)sectionController:(nonnull IGListBindingSectionController *)sectionController + contextMenuConfigurationForItemAtIndex:(NSInteger)index + point:(CGPoint)point + viewModel:(nonnull id)viewModel API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) { + return nil; +} + @end diff --git a/Tests/Objects/IGTestDiffingSectionController.h b/Tests/Objects/IGTestDiffingSectionController.h index 9fe520977..bd4f09d8e 100644 --- a/Tests/Objects/IGTestDiffingSectionController.h +++ b/Tests/Objects/IGTestDiffingSectionController.h @@ -13,5 +13,6 @@ @property (nonatomic, strong) id deselectedViewModel; @property (nonatomic, strong) id highlightedViewModel; @property (nonatomic, strong) id unhighlightedViewModel; +@property (nonatomic, strong) id contextMenuViewModel; @end diff --git a/Tests/Objects/IGTestDiffingSectionController.m b/Tests/Objects/IGTestDiffingSectionController.m index f7e798713..c4fd91323 100644 --- a/Tests/Objects/IGTestDiffingSectionController.m +++ b/Tests/Objects/IGTestDiffingSectionController.m @@ -65,4 +65,9 @@ - (void)sectionController:(IGListBindingSectionController *)sectionController di self.unhighlightedViewModel = viewModel; } +- (UIContextMenuConfiguration * _Nullable)sectionController:(IGListBindingSectionController *)sectionController contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point viewModel:(id)viewModel API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos) { + self.contextMenuViewModel = viewModel; + return nil; +} + @end