diff --git a/CHANGELOG.md b/CHANGELOG.md index 064845e06..c58b6e62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ The changelog for `IGListKit`. Also see the [releases](https://github.com/instag 4.1.0 (upcoming release) ----- +### Enhancements + - Introduce `IGListSwiftKit`, with Swift refinements for `dequeueReusableCellOfClass` methods. [Koen Punt](https://github.com/koenpunt) [(#1388)](https://github.com/Instagram/IGListKit/pull/1388). +- 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). + 4.0.0 ----- ### Breaking Changes diff --git a/Examples/Examples-iOS/IGListKitExamples/SectionControllers/MonthSectionController.swift b/Examples/Examples-iOS/IGListKitExamples/SectionControllers/MonthSectionController.swift index 3cbbbb55e..2c7bc2739 100644 --- a/Examples/Examples-iOS/IGListKitExamples/SectionControllers/MonthSectionController.swift +++ b/Examples/Examples-iOS/IGListKitExamples/SectionControllers/MonthSectionController.swift @@ -96,4 +96,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/IGListBindingSectionController.m b/Source/IGListKit/IGListBindingSectionController.m index ed25f453a..2e9667c90 100644 --- a/Source/IGListKit/IGListBindingSectionController.m +++ b/Source/IGListKit/IGListBindingSectionController.m @@ -150,4 +150,10 @@ - (void)didUnhighlightItemAtIndex:(NSInteger)index { [self.selectionDelegate sectionController:self didUnhighlightItemAtIndex:index viewModel:self.viewModels[index]]; } +#if !TARGET_OS_TV +- (nullable UIContextMenuConfiguration *)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point { + return [self.selectionDelegate sectionController:self contextMenuConfigurationForItemAtIndex:index point:point viewModel:self.viewModels[index]]; +} +#endif + @end diff --git a/Source/IGListKit/IGListBindingSectionControllerSelectionDelegate.h b/Source/IGListKit/IGListBindingSectionControllerSelectionDelegate.h index df6f2b9b9..9f3ae811a 100644 --- a/Source/IGListKit/IGListBindingSectionControllerSelectionDelegate.h +++ b/Source/IGListKit/IGListBindingSectionControllerSelectionDelegate.h @@ -61,6 +61,21 @@ NS_SWIFT_NAME(ListBindingSectionControllerSelectionDelegate) didUnhighlightItemAtIndex:(NSInteger)index viewModel:(id)viewModel; +/** + Tells the delegate that a cell has requested a menu configuration. + + @param sectionController The section controller the request of a menu configuration occurred in. + @param index The index of the cell that is being longed tap. + @param point The point of the tap on the cell. + @param viewModel The view model that was bound to the cell. + + @return An object that conforms to `UIContextMenuConfiguration`. + */ +- (nullable UIContextMenuConfiguration *)sectionController:(IGListBindingSectionController *)sectionController + contextMenuConfigurationForItemAtIndex:(NSInteger)index + point:(CGPoint)point + viewModel:(id)viewModel API_AVAILABLE(ios(13.0)) API_UNAVAILABLE(tvos); + @end NS_ASSUME_NONNULL_END diff --git a/Source/IGListKit/IGListSectionController.h b/Source/IGListKit/IGListSectionController.h index 68a455f4b..a5d15f2e1 100644 --- a/Source/IGListKit/IGListSectionController.h +++ b/Source/IGListKit/IGListSectionController.h @@ -111,6 +111,18 @@ 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.** + */ +- (nullable UIContextMenuConfiguration *)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point 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 24e698e7f..ed2b418ca 100644 --- a/Source/IGListKit/IGListSectionController.m +++ b/Source/IGListKit/IGListSectionController.m @@ -88,6 +88,10 @@ - (void)didHighlightItemAtIndex:(NSInteger)index {} - (void)didUnhighlightItemAtIndex:(NSInteger)index {} +- (nullable UIContextMenuConfiguration *)contextMenuConfigurationForItemAtIndex:(NSInteger)index point:(CGPoint)point { + 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 41780db8c..b1c77ff89 100644 --- a/Source/IGListKit/Internal/IGListAdapter+UICollectionView.m +++ b/Source/IGListKit/Internal/IGListAdapter+UICollectionView.m @@ -235,6 +235,19 @@ - (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:)]) { + [collectionViewDelegate collectionView:collectionView contextMenuConfigurationForItemAtIndexPath:indexPath point:point]; + } + + IGListSectionController * sectionController = [self sectionControllerForSection:indexPath.section]; + return [sectionController contextMenuConfigurationForItemAtIndex:indexPath.item point:point]; +} +#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 65e38f92a..086d1bff0 100644 --- a/Source/IGListKit/Internal/IGListAdapterProxy.m +++ b/Source/IGListKit/Internal/IGListAdapterProxy.m @@ -29,6 +29,7 @@ static BOOL isInterceptedSelector(SEL sel) { sel == @selector(collectionView:didDeselectItemAtIndexPath:) || sel == @selector(collectionView:didHighlightItemAtIndexPath:) || sel == @selector(collectionView:didUnhighlightItemAtIndexPath:) || + sel == @selector(collectionView:contextMenuConfigurationForItemAtIndexPath:point:) || // UICollectionViewDelegateFlowLayout sel == @selector(collectionView:layout:sizeForItemAtIndexPath:) || sel == @selector(collectionView:layout:insetForSectionAtIndex:) || diff --git a/Tests/IGListAdapterTests.m b/Tests/IGListAdapterTests.m index aa041fadf..c5e75e796 100644 --- a/Tests/IGListAdapterTests.m +++ b/Tests/IGListAdapterTests.m @@ -1425,7 +1425,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]; @@ -1443,6 +1443,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 7ba24829a..049a17b30 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..247291a47 100644 --- a/Tests/Objects/IGListTestSection.m +++ b/Tests/Objects/IGListTestSection.m @@ -60,6 +60,11 @@ - (void)didUnhighlightItemAtIndex:(NSInteger)index { self.wasUnhighlighted = YES; } +- (nullable UIContextMenuConfiguration *)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..53bde9d10 100644 --- a/Tests/Objects/IGTestBindingWithoutDeselectionDelegate.m +++ b/Tests/Objects/IGTestBindingWithoutDeselectionDelegate.m @@ -31,4 +31,12 @@ - (void)sectionController:(nonnull IGListBindingSectionController *)sectionContr viewModel:(nonnull id)viewModel { } + +- (nullable UIContextMenuConfiguration *)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..3d94be52f 100644 --- a/Tests/Objects/IGTestDiffingSectionController.m +++ b/Tests/Objects/IGTestDiffingSectionController.m @@ -65,4 +65,9 @@ - (void)sectionController:(IGListBindingSectionController *)sectionController di self.unhighlightedViewModel = viewModel; } +- (nullable UIContextMenuConfiguration *)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